From de072fd0c4d03c949d9a406e50b8a3e7b4351ef6 Mon Sep 17 00:00:00 2001 From: Lucas Schwiderski Date: Wed, 1 Mar 2023 19:51:28 +0100 Subject: [PATCH] feat(dtmm): Save the mod list Closes #9. --- Cargo.lock | 3 + crates/dtmm/Cargo.toml | 5 +- crates/dtmm/src/controller/app.rs | 109 ++++++++++++++++++++---- crates/dtmm/src/main.rs | 12 ++- crates/dtmm/src/state/data.rs | 10 +-- crates/dtmm/src/state/delegate.rs | 7 +- crates/dtmm/src/state/mod.rs | 2 - crates/dtmm/src/state/util.rs | 49 ----------- crates/dtmm/src/ui/widget/controller.rs | 39 ++++++--- crates/dtmm/src/ui/widget/mod.rs | 49 +++++++++++ crates/dtmm/src/ui/window/main.rs | 17 ++-- crates/dtmm/src/util/config.rs | 49 ++++++++++- 12 files changed, 247 insertions(+), 104 deletions(-) delete mode 100644 crates/dtmm/src/state/util.rs diff --git a/Cargo.lock b/Cargo.lock index 1ebc2cc..d10dfcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -680,6 +680,7 @@ dependencies = [ "serde", "serde_sjson", "tokio", + "tokio-stream", "tracing", "tracing-error", "tracing-subscriber", @@ -1261,6 +1262,7 @@ dependencies = [ "bitmaps", "rand_core", "rand_xoshiro", + "serde", "sized-chunks", "typenum", "version_check", @@ -1375,6 +1377,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e119590a03caff1f7a582e8ee8c2164ddcc975791701188132fd1d1b518d3871" dependencies = [ "arrayvec", + "serde", ] [[package]] diff --git a/crates/dtmm/Cargo.toml b/crates/dtmm/Cargo.toml index 65b1bca..6f42d88 100644 --- a/crates/dtmm/Cargo.toml +++ b/crates/dtmm/Cargo.toml @@ -10,15 +10,16 @@ bitflags = "1.3.2" clap = { version = "4.0.15", features = ["color", "derive", "std", "cargo", "string", "unicode"] } color-eyre = "0.6.2" confy = "0.5.1" -druid = { git = "https://github.com/linebender/druid.git", features = ["im"] } +druid = { git = "https://github.com/linebender/druid.git", features = ["im", "serde"] } dtmt-shared = { path = "../../lib/dtmt-shared", version = "*" } futures = "0.3.25" oodle-sys = { path = "../../lib/oodle-sys", version = "*" } sdk = { path = "../../lib/sdk", version = "0.2.0" } serde_sjson = { path = "../../lib/serde_sjson", version = "*" } -serde = { version = "1.0.152", features = ["derive"] } +serde = { version = "1.0.152", features = ["derive", "rc"] } tokio = { version = "1.23.0", features = ["rt", "fs", "tracing", "sync"] } tracing = "0.1.37" tracing-error = "0.2.0" tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } zip = "0.6.4" +tokio-stream = { version = "0.1.12", features = ["fs"] } diff --git a/crates/dtmm/src/controller/app.rs b/crates/dtmm/src/controller/app.rs index 0171ed6..d8cb619 100644 --- a/crates/dtmm/src/controller/app.rs +++ b/crates/dtmm/src/controller/app.rs @@ -1,15 +1,21 @@ use std::collections::HashMap; -use std::io::{Cursor, Read}; +use std::io::{Cursor, ErrorKind, Read}; use std::path::Path; use color_eyre::eyre::{self, Context}; use color_eyre::{Help, Result}; +use druid::im::Vector; use druid::FileInfo; use dtmt_shared::ModConfig; -use tokio::fs; +use serde::Deserialize; +use tokio::fs::{self, DirEntry}; +use tokio::runtime::Runtime; +use tokio_stream::wrappers::ReadDirStream; +use tokio_stream::StreamExt; use zip::ZipArchive; use crate::state::{ModInfo, PackageInfo, State}; +use crate::util::config::{ConfigSerialize, LoadOrderEntry}; #[tracing::instrument(skip(state))] pub(crate) async fn import_mod(state: State, info: FileInfo) -> Result { @@ -105,24 +111,9 @@ pub(crate) async fn delete_mod(state: State, info: &ModInfo) -> Result<()> { Ok(()) } -#[derive(Debug, serde::Serialize)] -struct Config<'a> { - game_dir: &'a Path, - data_dir: &'a Path, -} - -impl<'a> From<&'a State> for Config<'a> { - fn from(value: &'a State) -> Self { - Self { - game_dir: &value.game_dir, - data_dir: &value.data_dir, - } - } -} - #[tracing::instrument(skip(state))] pub(crate) async fn save_settings(state: State) -> Result<()> { - let cfg = Config::from(&state); + let cfg = ConfigSerialize::from(&state); tracing::info!("Saving settings to '{}'", state.config_path.display()); tracing::debug!(?cfg); @@ -138,3 +129,85 @@ pub(crate) async fn save_settings(state: State) -> Result<()> { ) }) } + +async fn read_sjson_file(path: P) -> Result +where + T: for<'a> Deserialize<'a>, + P: AsRef + std::fmt::Debug, +{ + let buf = fs::read(path).await.wrap_err("failed to read file")?; + let data = String::from_utf8(buf).wrap_err("invalid UTF8")?; + serde_sjson::from_str(&data).wrap_err("failed to deserialize") +} + +#[tracing::instrument(skip_all,fields( + name = ?res.as_ref().map(|entry| entry.file_name()) +))] +async fn read_mod_dir_entry(res: Result) -> Result { + let entry = res?; + let config_path = entry.path().join("dtmt.cfg"); + let index_path = entry.path().join("files.sjson"); + + let cfg: ModConfig = read_sjson_file(&config_path) + .await + .wrap_err_with(|| format!("failed to read mod config '{}'", config_path.display()))?; + + let files: HashMap> = read_sjson_file(&index_path) + .await + .wrap_err_with(|| format!("failed to read file index '{}'", index_path.display()))?; + + let packages = files + .into_iter() + .map(|(name, files)| PackageInfo::new(name, files.into_iter().collect())) + .collect(); + let info = ModInfo::new(cfg, packages); + Ok(info) +} + +#[tracing::instrument(skip(mod_order))] +pub(crate) fn load_mods<'a, P, S>(mod_dir: P, mod_order: S) -> Result> +where + S: Iterator, + P: AsRef + std::fmt::Debug, +{ + let rt = Runtime::new()?; + + rt.block_on(async move { + let mod_dir = mod_dir.as_ref(); + let read_dir = match fs::read_dir(mod_dir).await { + Ok(read_dir) => read_dir, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(Vector::new()); + } + Err(err) => { + return Err(err) + .wrap_err_with(|| format!("failed to open directory '{}'", mod_dir.display())); + } + }; + + let stream = ReadDirStream::new(read_dir) + .map(|res| res.wrap_err("failed to read dir entry")) + .then(read_mod_dir_entry); + tokio::pin!(stream); + + let mut mods: HashMap = HashMap::new(); + + while let Some(res) = stream.next().await { + let info = res?; + mods.insert(info.id.clone(), info); + } + + let mods = mod_order + .filter_map(|entry| { + if let Some(mut info) = mods.remove(&entry.id) { + info.enabled = entry.enabled; + Some(info) + } else { + None + } + }) + .collect(); + + Ok::<_, color_eyre::Report>(mods) + }) +} diff --git a/crates/dtmm/src/main.rs b/crates/dtmm/src/main.rs index 885cfcf..fdce5ab 100644 --- a/crates/dtmm/src/main.rs +++ b/crates/dtmm/src/main.rs @@ -12,6 +12,7 @@ use color_eyre::{Report, Result}; use druid::AppLauncher; use tokio::sync::RwLock; +use crate::controller::app::load_mods; use crate::controller::worker::work_thread; use crate::state::{Delegate, State}; @@ -62,7 +63,16 @@ fn main() -> Result<()> { let config = util::config::read_config(&default_config_path, &matches) .wrap_err("failed to read config file")?; - let initial_state = State::new(config); + let initial_state = { + let mut state = State::new( + config.path, + config.game_dir.unwrap_or_default(), + config.data_dir.unwrap_or_default(), + ); + state.mods = load_mods(state.get_mod_dir(), config.mod_order.iter()) + .wrap_err("failed to load mods")?; + state + }; let (action_tx, action_rx) = tokio::sync::mpsc::unbounded_channel(); let delegate = Delegate::new(action_tx); diff --git a/crates/dtmm/src/state/data.rs b/crates/dtmm/src/state/data.rs index 63bfe41..c8dc3aa 100644 --- a/crates/dtmm/src/state/data.rs +++ b/crates/dtmm/src/state/data.rs @@ -3,8 +3,6 @@ use std::{path::PathBuf, sync::Arc}; use druid::{im::Vector, Data, Lens}; use dtmt_shared::ModConfig; -use crate::util::config::Config; - use super::SelectedModLens; #[derive(Copy, Clone, Data, Debug, PartialEq)] @@ -100,7 +98,7 @@ impl State { #[allow(non_upper_case_globals)] pub const selected_mod: SelectedModLens = SelectedModLens; - pub fn new(config: Config) -> Self { + pub fn new(config_path: PathBuf, game_dir: PathBuf, data_dir: PathBuf) -> Self { let ctx = sdk::Context::new(); Self { @@ -112,9 +110,9 @@ impl State { is_reset_in_progress: false, 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()), + config_path: Arc::new(config_path), + game_dir: Arc::new(game_dir), + data_dir: Arc::new(data_dir), log: Arc::new(String::new()), } } diff --git a/crates/dtmm/src/state/delegate.rs b/crates/dtmm/src/state/delegate.rs index 6aab016..08d17b0 100644 --- a/crates/dtmm/src/state/delegate.rs +++ b/crates/dtmm/src/state/delegate.rs @@ -106,6 +106,7 @@ impl AppDelegate for Delegate { .expect("command type matched but didn't contain the expected value"); state.select_mod(*index); + // ctx.submit_command(ACTION_START_SAVE_SETTINGS); Handled::Yes } cmd if cmd.is(ACTION_SELECTED_MOD_UP) => { @@ -120,6 +121,7 @@ impl AppDelegate for Delegate { state.mods.swap(i, i - 1); state.selected_mod_index = Some(i - 1); + // ctx.submit_command(ACTION_START_SAVE_SETTINGS); Handled::Yes } cmd if cmd.is(ACTION_SELECTED_MOD_DOWN) => { @@ -134,6 +136,7 @@ impl AppDelegate for Delegate { state.mods.swap(i, i + 1); state.selected_mod_index = Some(i + 1); + // ctx.submit_command(ACTION_START_SAVE_SETTINGS); Handled::Yes } cmd if cmd.is(ACTION_START_DELETE_SELECTED_MOD) => { @@ -162,7 +165,7 @@ impl AppDelegate for Delegate { }; state.mods.remove(index); - ctx.submit_command(ACTION_START_SAVE_SETTINGS); + // ctx.submit_command(ACTION_START_SAVE_SETTINGS); Handled::Yes } @@ -185,7 +188,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); + // ctx.submit_command(ACTION_START_SAVE_SETTINGS); } Handled::Yes } diff --git a/crates/dtmm/src/state/mod.rs b/crates/dtmm/src/state/mod.rs index 1586e3c..f0eb8c3 100644 --- a/crates/dtmm/src/state/mod.rs +++ b/crates/dtmm/src/state/mod.rs @@ -1,9 +1,7 @@ mod data; mod delegate; mod lens; -mod util; pub(crate) use data::*; pub(crate) use delegate::*; pub(crate) use lens::*; -pub(crate) use util::*; diff --git a/crates/dtmm/src/state/util.rs b/crates/dtmm/src/state/util.rs deleted file mode 100644 index 1776d19..0000000 --- a/crates/dtmm/src/state/util.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use druid::text::Formatter; -use druid::widget::{TextBoxEvent, ValidationDelegate}; -use druid::EventCtx; - -pub(crate) struct PathBufFormatter; - -impl PathBufFormatter { - pub fn new() -> Self { - Self {} - } -} - -impl Formatter> for PathBufFormatter { - fn format(&self, value: &Arc) -> String { - value.display().to_string() - } - - fn validate_partial_input( - &self, - _input: &str, - _sel: &druid::text::Selection, - ) -> druid::text::Validation { - druid::text::Validation::success() - } - - fn value(&self, input: &str) -> Result, druid::text::ValidationError> { - let p = PathBuf::from(input); - 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/widget/controller.rs b/crates/dtmm/src/ui/widget/controller.rs index f7f71ef..ce18d5b 100644 --- a/crates/dtmm/src/ui/widget/controller.rs +++ b/crates/dtmm/src/ui/widget/controller.rs @@ -1,5 +1,7 @@ use druid::widget::{Button, Controller, Scroll}; -use druid::{Data, Env, Event, EventCtx, LifeCycle, LifeCycleCtx, Rect, UpdateCtx, Widget}; +use druid::{Data, Env, Event, EventCtx, Rect, UpdateCtx, Widget}; + +use crate::state::{State, ACTION_START_SAVE_SETTINGS}; pub struct DisabledButtonController; @@ -19,17 +21,6 @@ impl Controller> for DisabledButtonController { child.event(ctx, event, data, env) } - fn lifecycle( - &mut self, - child: &mut Button, - ctx: &mut LifeCycleCtx, - event: &LifeCycle, - data: &T, - env: &Env, - ) { - child.lifecycle(ctx, event, data, env) - } - fn update( &mut self, child: &mut Button, @@ -65,3 +56,27 @@ impl> Controller> for AutoScrollController child.update(ctx, old_data, data, env) } } + +/// A controller that submits the command to save settings every time its widget's +/// data changes. +pub struct SaveSettingsController; + +impl> Controller for SaveSettingsController { + fn update( + &mut self, + child: &mut W, + ctx: &mut UpdateCtx, + old_data: &State, + data: &State, + env: &Env, + ) { + // Only filter for the values that actually go into the settings file. + if old_data.mods != data.mods + || old_data.game_dir != data.game_dir + || old_data.data_dir != data.data_dir + { + ctx.submit_command(ACTION_START_SAVE_SETTINGS); + } + child.update(ctx, old_data, data, env) + } +} diff --git a/crates/dtmm/src/ui/widget/mod.rs b/crates/dtmm/src/ui/widget/mod.rs index 84a57a2..801ad0a 100644 --- a/crates/dtmm/src/ui/widget/mod.rs +++ b/crates/dtmm/src/ui/widget/mod.rs @@ -1,3 +1,9 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use druid::text::Formatter; +use druid::widget::{TextBoxEvent, ValidationDelegate}; +use druid::EventCtx; use druid::{Data, Widget}; pub mod container; @@ -6,3 +12,46 @@ pub mod controller; pub trait ExtraWidgetExt: Widget + Sized + 'static {} impl + 'static> ExtraWidgetExt for W {} + +pub(crate) struct PathBufFormatter; + +impl PathBufFormatter { + pub fn new() -> Self { + Self {} + } +} + +impl Formatter> for PathBufFormatter { + fn format(&self, value: &Arc) -> String { + value.display().to_string() + } + + fn validate_partial_input( + &self, + _input: &str, + _sel: &druid::text::Selection, + ) -> druid::text::Validation { + druid::text::Validation::success() + } + + fn value(&self, input: &str) -> Result, druid::text::ValidationError> { + let p = PathBuf::from(input); + 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 5544367..a0ccaa2 100644 --- a/crates/dtmm/src/ui/window/main.rs +++ b/crates/dtmm/src/ui/window/main.rs @@ -10,13 +10,13 @@ use druid::{ }; use crate::state::{ - ModInfo, PathBufFormatter, State, TextBoxOnChanged, View, ACTION_ADD_MOD, - ACTION_SELECTED_MOD_DOWN, ACTION_SELECTED_MOD_UP, ACTION_SELECT_MOD, - ACTION_START_DELETE_SELECTED_MOD, ACTION_START_DEPLOY, ACTION_START_RESET_DEPLOYMENT, - ACTION_START_SAVE_SETTINGS, + ModInfo, State, View, ACTION_ADD_MOD, ACTION_SELECTED_MOD_DOWN, ACTION_SELECTED_MOD_UP, + ACTION_SELECT_MOD, ACTION_START_DELETE_SELECTED_MOD, ACTION_START_DEPLOY, + ACTION_START_RESET_DEPLOYMENT, }; use crate::ui::theme; -use crate::ui::widget::controller::AutoScrollController; +use crate::ui::widget::controller::{AutoScrollController, SaveSettingsController}; +use crate::ui::widget::PathBufFormatter; const TITLE: &str = "Darktide Mod Manager"; const WINDOW_SIZE: (f64, f64) = (1080., 720.); @@ -248,9 +248,6 @@ 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., @@ -265,9 +262,6 @@ 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., @@ -318,4 +312,5 @@ fn build_window() -> impl Widget { .with_child(build_top_bar()) .with_flex_child(build_main(), 1.0) .with_child(build_log_view()) + .controller(SaveSettingsController) } diff --git a/crates/dtmm/src/util/config.rs b/crates/dtmm/src/util/config.rs index 0008edf..d2ae44c 100644 --- a/crates/dtmm/src/util/config.rs +++ b/crates/dtmm/src/util/config.rs @@ -1,17 +1,63 @@ -use std::fs; use std::io::ErrorKind; use std::path::PathBuf; +use std::{fs, path::Path}; use clap::{parser::ValueSource, ArgMatches}; use color_eyre::{eyre::Context, Result}; use serde::{Deserialize, Serialize}; +use crate::state::{ModInfo, State}; + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct LoadOrderEntrySerialize<'a> { + pub id: &'a String, + pub enabled: bool, +} + +impl<'a> From<&'a ModInfo> for LoadOrderEntrySerialize<'a> { + fn from(info: &'a ModInfo) -> Self { + Self { + id: &info.id, + enabled: info.enabled, + } + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct ConfigSerialize<'a> { + game_dir: &'a Path, + data_dir: &'a Path, + mod_order: Vec>, +} + +impl<'a> From<&'a State> for ConfigSerialize<'a> { + fn from(state: &'a State) -> Self { + Self { + game_dir: &state.game_dir, + data_dir: &state.data_dir, + mod_order: state + .mods + .iter() + .map(LoadOrderEntrySerialize::from) + .collect(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct LoadOrderEntry { + pub id: String, + pub enabled: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub(crate) struct Config { #[serde(skip)] pub path: PathBuf, pub data_dir: Option, pub game_dir: Option, + #[serde(default)] + pub mod_order: Vec, } #[cfg(not(arget_os = "windows"))] @@ -92,6 +138,7 @@ where path: default_path, data_dir: Some(get_default_data_dir()), game_dir: None, + mod_order: Vec::new(), }; {