diff --git a/Cargo.lock b/Cargo.lock index 5f2a35a..a46c278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c676a478f63e9fa2dd5368a42f28bba0d6c560b775f38583c8bbaa7fcd67c9c" +[[package]] +name = "bytemuck" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea" + [[package]] name = "byteorder" version = "1.4.3" @@ -399,6 +405,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "confy" version = "0.5.1" @@ -725,6 +737,7 @@ dependencies = [ name = "dtmt" version = "0.3.0" dependencies = [ + "async-recursion", "clap", "cli-table", "color-eyre", @@ -1314,6 +1327,21 @@ dependencies = [ "version_check", ] +[[package]] +name = "image" +version = "0.24.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b7ea949b537b0fd0af141fff8c77690f2ce96f4f41f042ccb6c69c6c965945" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "jpeg-decoder", + "num-rational 0.4.1", + "num-traits 0.2.15", + "png", +] + [[package]] name = "indenter" version = "0.3.3" @@ -1398,6 +1426,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" + [[package]] name = "js-sys" version = "0.3.61" @@ -1635,7 +1669,7 @@ dependencies = [ "num-complex", "num-integer", "num-iter", - "num-rational", + "num-rational 0.3.2", "num-traits 0.2.15", ] @@ -1692,6 +1726,17 @@ dependencies = [ "num-traits 0.2.15", ] +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits 0.2.15", +] + [[package]] name = "num-traits" version = "0.1.43" @@ -1924,6 +1969,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e381186490a3e2017a506d62b759ea8eaf4be14666b13ed53973e8ae193451b1" dependencies = [ + "image", "kurbo", "unic-bidi", ] @@ -2022,6 +2068,18 @@ version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" +[[package]] +name = "png" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d708eaf860a19b19ce538740d2b4bdeeb8337fa53f7738455e706623ad5c638" +dependencies = [ + "bitflags", + "crc32fast", + "flate2", + "miniz_oxide", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" diff --git a/crates/dtmm/Cargo.toml b/crates/dtmm/Cargo.toml index 5e11a9b..05db4e0 100644 --- a/crates/dtmm/Cargo.toml +++ b/crates/dtmm/Cargo.toml @@ -10,7 +10,7 @@ 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", "serde"] } +druid = { git = "https://github.com/linebender/druid.git", features = ["im", "serde", "image", "png", "jpeg", "bmp", "webp"] } dtmt-shared = { path = "../../lib/dtmt-shared", version = "*" } futures = "0.3.25" oodle-sys = { path = "../../lib/oodle-sys", version = "*" } diff --git a/crates/dtmm/src/controller/app.rs b/crates/dtmm/src/controller/app.rs index c36e7f2..9aa5a6d 100644 --- a/crates/dtmm/src/controller/app.rs +++ b/crates/dtmm/src/controller/app.rs @@ -4,9 +4,9 @@ use std::path::Path; use std::sync::Arc; use color_eyre::eyre::{self, Context}; -use color_eyre::{Help, Result}; +use color_eyre::{Help, Report, Result}; use druid::im::Vector; -use druid::FileInfo; +use druid::{FileInfo, ImageBuf}; use dtmt_shared::ModConfig; use tokio::fs::{self, DirEntry}; use tokio::runtime::Runtime; @@ -95,6 +95,36 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result img, + Err(err) => { + let err = Report::msg(err.to_string()).wrap_err("Invalid image data"); + return Err(err).with_suggestion(|| { + "Supported formats are: PNG, JPEG, Bitmap and WebP".to_string() + }); + } + }; + + Some(img) + } else { + None + }; + let mod_dir = state.mod_dir; tracing::trace!("Creating mods directory {}", mod_dir.display()); @@ -111,7 +141,7 @@ pub(crate) async fn import_mod(state: ActionState, info: FileInfo) -> Result) -> Result { .await .wrap_err_with(|| format!("Failed to read file index '{}'", index_path.display()))?; + let image = if let Some(path) = &cfg.image { + let path = entry.path().join(path); + if let Ok(data) = fs::read(&path).await { + // Druid somehow doesn't return an error compatible with eyre, here. + // So we have to wrap through `Display` manually. + let img = match ImageBuf::from_data(&data) { + Ok(img) => img, + Err(err) => { + let err = Report::msg(err.to_string()); + return Err(err) + .wrap_err_with(|| { + format!("Failed to import image file '{}'", path.display()) + }) + .with_suggestion(|| { + "Supported formats are: PNG, JPEG, Bitmap and WebP".to_string() + }); + } + }; + + Some(img) + } else { + None + } + } else { + None + }; + let packages = files .into_iter() .map(|(name, files)| Arc::new(PackageInfo::new(name, files.into_iter().collect()))) .collect(); - let info = ModInfo::new(cfg, packages); + let info = ModInfo::new(cfg, packages, image); Ok(info) } diff --git a/crates/dtmm/src/state/data.rs b/crates/dtmm/src/state/data.rs index 779b67c..34ab92d 100644 --- a/crates/dtmm/src/state/data.rs +++ b/crates/dtmm/src/state/data.rs @@ -2,7 +2,7 @@ use std::{path::PathBuf, sync::Arc}; use druid::{ im::{HashMap, Vector}, - Data, Lens, WindowHandle, WindowId, + Data, ImageBuf, Lens, WindowHandle, WindowId, }; use dtmt_shared::ModConfig; @@ -69,11 +69,16 @@ impl From for ModDependency { } } -#[derive(Clone, Data, Debug, Lens, PartialEq)] +#[derive(Clone, Data, Debug, Lens)] pub(crate) struct ModInfo { pub id: String, pub name: String, - pub description: Arc, + pub summary: Arc, + pub description: Option>, + pub categories: Vector, + pub author: Option, + pub image: Option, + pub version: String, pub enabled: bool, #[lens(ignore)] #[data(ignore)] @@ -85,13 +90,22 @@ pub(crate) struct ModInfo { } impl ModInfo { - pub fn new(cfg: ModConfig, packages: Vector>) -> Self { + pub fn new( + cfg: ModConfig, + packages: Vector>, + image: Option, + ) -> Self { Self { id: cfg.id, name: cfg.name, - description: Arc::new(cfg.description), + summary: Arc::new(cfg.summary), + description: cfg.description.map(Arc::new), + author: cfg.author, + version: cfg.version, enabled: false, packages, + image, + categories: cfg.categories.into_iter().collect(), resources: ModResourceInfo { init: cfg.resources.init, data: cfg.resources.data, diff --git a/crates/dtmm/src/state/delegate.rs b/crates/dtmm/src/state/delegate.rs index 506cbf8..73aa42d 100644 --- a/crates/dtmm/src/state/delegate.rs +++ b/crates/dtmm/src/state/delegate.rs @@ -259,6 +259,11 @@ impl AppDelegate for Delegate { Handled::Yes } cmd if cmd.is(ACTION_FINISH_SAVE_SETTINGS) => { + tracing::trace!( + in_progress = state.is_save_in_progress, + next_pending = state.is_next_save_pending, + "Finished saving settings", + ); state.is_save_in_progress = false; if state.is_next_save_pending { diff --git a/crates/dtmm/src/ui/widget/controller.rs b/crates/dtmm/src/ui/widget/controller.rs index 1bd7f6a..a7ff2f1 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, Rect, UpdateCtx, Widget}; +use druid::widget::{Button, Controller, Image, Scroll}; +use druid::{ + Data, Env, Event, EventCtx, ImageBuf, LifeCycle, LifeCycleCtx, Rect, UpdateCtx, Widget, +}; use crate::state::{State, ACTION_SET_DIRTY, ACTION_START_SAVE_SETTINGS}; @@ -48,18 +50,19 @@ impl> Controller> for AutoScrollController data: &T, env: &Env, ) { + child.update(ctx, old_data, data, env); + if !ctx.is_disabled() { let size = child.child_size(); let end_region = Rect::new(size.width - 1., size.height - 1., size.width, size.height); child.scroll_to(ctx, end_region); } - child.update(ctx, old_data, data, env) } } macro_rules! compare_state_fields { ($old:ident, $new:ident, $($field:ident),+) => { - $($old.$field != $new.$field) || + + $(!Data::same(&$old.$field, &$new.$field)) || + } } @@ -86,3 +89,37 @@ impl> Controller for DirtyStateController { child.update(ctx, old_data, data, env) } } + +pub struct ImageLensController; + +impl Controller for ImageLensController { + fn lifecycle( + &mut self, + widget: &mut Image, + ctx: &mut LifeCycleCtx, + event: &LifeCycle, + data: &ImageBuf, + env: &Env, + ) { + if let LifeCycle::WidgetAdded = event { + widget.set_image_data(data.clone()); + } + + widget.lifecycle(ctx, event, data, env); + } + + fn update( + &mut self, + widget: &mut Image, + ctx: &mut UpdateCtx, + old_data: &ImageBuf, + data: &ImageBuf, + env: &Env, + ) { + if !Data::same(old_data, data) { + widget.set_image_data(data.clone()); + } + + widget.update(ctx, old_data, data, env); + } +} diff --git a/crates/dtmm/src/ui/window/main.rs b/crates/dtmm/src/ui/window/main.rs index f48b5c3..c8d19a8 100644 --- a/crates/dtmm/src/ui/window/main.rs +++ b/crates/dtmm/src/ui/window/main.rs @@ -2,10 +2,10 @@ use std::sync::Arc; use druid::im::Vector; use druid::widget::{ - Button, Checkbox, CrossAxisAlignment, Flex, Label, LineBreaking, List, MainAxisAlignment, - Maybe, Scroll, SizedBox, Split, TextBox, ViewSwitcher, + Button, Checkbox, CrossAxisAlignment, Flex, Image, Label, LineBreaking, List, + MainAxisAlignment, Maybe, Scroll, SizedBox, Split, TextBox, ViewSwitcher, }; -use druid::{lens, LifeCycleCtx}; +use druid::{lens, Data, ImageBuf, LifeCycleCtx}; use druid::{ Color, FileDialogOptions, FileSpec, FontDescriptor, FontFamily, Key, LensExt, SingleUse, TextAlignment, Widget, WidgetExt, WindowDesc, WindowId, @@ -18,7 +18,9 @@ use crate::state::{ ACTION_START_DEPLOY, ACTION_START_RESET_DEPLOYMENT, }; use crate::ui::theme; -use crate::ui::widget::controller::{AutoScrollController, DirtyStateController}; +use crate::ui::widget::controller::{ + AutoScrollController, DirtyStateController, ImageLensController, +}; use crate::ui::widget::PathBufFormatter; lazy_static! { @@ -124,7 +126,7 @@ fn build_mod_list() -> impl Widget { }, |state, infos| { infos.into_iter().for_each(|(i, new, _)| { - if state.mods.get(i).cloned() != Some(new.clone()) { + if Data::same(&state.mods.get(i).cloned(), &Some(new.clone())) { state.mods.set(i, new); } }); @@ -220,20 +222,64 @@ fn build_mod_details_info() -> impl Widget { // so that we can center-align it. .expand_width() .lens(ModInfo::name.in_arc()); - let description = Label::raw() + let summary = Label::raw() .with_line_break_mode(LineBreaking::WordWrap) - .lens(ModInfo::description.in_arc()); + .lens(ModInfo::summary.in_arc()); - Flex::column() + // TODO: Image/icon? + + let version_line = Label::dynamic(|info: &Arc, _| { + if let Some(author) = &info.author { + format!("Version: {}, by {author}", info.version) + } else { + format!("Version: {}", info.version) + } + }); + + let categories = Label::dynamic(|info: &Arc, _| { + if info.categories.is_empty() { + String::from("Uncategorized") + } else { + info.categories.iter().enumerate().fold( + String::from("Category: "), + |mut s, (i, category)| { + if i > 0 { + s.push_str(", "); + } + s.push_str(category); + s + }, + ) + } + }); + + let details = Flex::column() .cross_axis_alignment(CrossAxisAlignment::Start) .main_axis_alignment(MainAxisAlignment::Start) .with_child(name) .with_spacer(4.) - .with_child(description) + .with_child(summary) + .with_spacer(4.) + .with_child(version_line) + .with_spacer(4.) + .with_child(categories) + .padding((4., 4.)); + + let image = + Maybe::or_empty(|| Image::new(ImageBuf::empty()).controller(ImageLensController)) + .lens(ModInfo::image.in_arc()); + + Flex::column() + .main_axis_alignment(MainAxisAlignment::Start) + .must_fill_main_axis(true) + .cross_axis_alignment(CrossAxisAlignment::Start) + .with_child(image) + // .with_spacer(4.) + // .with_flex_child(details, 1.) + .with_child(details) }, Flex::column, ) - .padding((4., 4.)) .lens(State::selected_mod) } diff --git a/crates/dtmt/Cargo.toml b/crates/dtmt/Cargo.toml index 8b210e5..6ed04de 100644 --- a/crates/dtmt/Cargo.toml +++ b/crates/dtmt/Cargo.toml @@ -30,6 +30,7 @@ tracing = { version = "0.1.37", features = ["async-await"] } zip = "0.6.3" path-clean = "1.0.1" path-slash = "0.2.1" +async-recursion = "1.0.2" [dev-dependencies] tempfile = "3.3.0" diff --git a/crates/dtmt/src/cmd/build.rs b/crates/dtmt/src/cmd/build.rs index 523dccd..b8f23a8 100644 --- a/crates/dtmt/src/cmd/build.rs +++ b/crates/dtmt/src/cmd/build.rs @@ -201,6 +201,17 @@ fn normalize_file_path>(path: P) -> Result { pub(crate) async fn read_project_config(dir: Option) -> Result { let mut cfg = find_project_config(dir).await?; + if let Some(path) = cfg.image { + let path = normalize_file_path(path) + .wrap_err("Invalid config field 'image'") + .with_suggestion(|| { + "Specify a file path relative to and child path of the \ + directory where 'dtmt.cfg' is." + .to_string() + })?; + cfg.image = Some(path); + } + cfg.resources.init = normalize_file_path(cfg.resources.init) .wrap_err("Invalid config field 'resources.init'") .with_suggestion(|| { @@ -349,14 +360,36 @@ pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()> .wrap_err("Failed to build mod bundles")?; { + let path = out_path.join("files.sjson"); + tracing::trace!(path = %path.display(), "Writing file index"); let file_map = file_map.lock().await; let data = serde_sjson::to_string(file_map.deref())?; - let path = out_path.join("files.sjson"); fs::write(&path, data) .await .wrap_err_with(|| format!("Failed to write file index to '{}'", path.display()))?; } + if let Some(img_path) = &cfg.image { + let path = cfg.dir.join(img_path); + let dest = out_path.join(img_path); + + tracing::trace!(src = %path.display(), dest = %dest.display(), "Copying image file"); + + if let Some(parent) = dest.parent() { + fs::create_dir_all(&parent) + .await + .wrap_err_with(|| format!("Failed to create directory '{}'", parent.display()))?; + } + + fs::copy(&path, &dest).await.wrap_err_with(|| { + format!( + "Failed to copy image from '{}' to '{}'", + path.display(), + dest.display() + ) + })?; + } + tracing::info!("Compiled bundles written to '{}'", out_path.display()); if let Some(game_dir) = game_dir.as_ref() { diff --git a/crates/dtmt/src/cmd/new.rs b/crates/dtmt/src/cmd/new.rs index 77fa649..eb6a4f9 100644 --- a/crates/dtmt/src/cmd/new.rs +++ b/crates/dtmt/src/cmd/new.rs @@ -13,8 +13,28 @@ const TEMPLATES: [(&str, &str); 5] = [ "dtmt.cfg", r#"id = "{{id}}" name = "{{name}}" -description = "This is my new mod '{{name}}'!" version = "0.1.0" +// author = "" + +// A one- or two-line short description. +summary = "This is my new mod '{{name}}'!" +// description = "" +// image = "assets/logo.png" + +// Can contain arbitrary strings. But to keep things consistent and useful, +// capitalize names and check existing mods for matching categories. +categories = [ + Misc + // UI + // QoL + // Tools +] + +// A list of mod IDs that this mod depends on. You can find +// those IDs by downloading the mod and extracting their `dtmt.cfg`. +depends = [ + DMF +] resources = { init = "scripts/mods/{{id}}/init" @@ -23,16 +43,12 @@ resources = { } packages = [ - "packages/{{id}}" -] - -depends = [ - "dmf" + "packages/mods/{{id}}" ] "#, ), ( - "packages/{{id}}.package", + "packages/mods/{{id}}.package", r#"lua = [ "scripts/mods/{{id}}/*" ] diff --git a/crates/dtmt/src/cmd/package.rs b/crates/dtmt/src/cmd/package.rs index eab826c..f4d990e 100644 --- a/crates/dtmt/src/cmd/package.rs +++ b/crates/dtmt/src/cmd/package.rs @@ -1,12 +1,13 @@ -use std::ffi::OsString; use std::io::{Cursor, Write}; use std::path::PathBuf; +use std::sync::Arc; use clap::{value_parser, Arg, ArgMatches, Command}; use color_eyre::eyre::{Context, Result}; use color_eyre::Help; use path_slash::PathBufExt; -use tokio::fs::{self, DirEntry}; +use tokio::fs; +use tokio::sync::Mutex; use tokio_stream::wrappers::ReadDirStream; use tokio_stream::StreamExt; use zip::ZipWriter; @@ -46,15 +47,45 @@ pub(crate) fn command_definition() -> Command { ) } -async fn process_dir_entry(res: Result) -> Result<(OsString, Vec)> { - let entry = res?; - let path = entry.path(); - - let data = fs::read(&path) +#[async_recursion::async_recursion] +async fn process_directory( + zip: Arc>>, + path: PathBuf, + prefix: PathBuf, +) -> Result<()> { + zip.lock() .await - .wrap_err_with(|| format!("Failed to read '{}'", path.display()))?; + .add_directory(prefix.to_slash_lossy(), Default::default())?; - Ok((entry.file_name(), data)) + let read_dir = fs::read_dir(&path) + .await + .wrap_err_with(|| format!("Failed to read directory '{}'", path.display()))?; + + let stream = ReadDirStream::new(read_dir).map(|res| res.wrap_err("Failed to read dir entry")); + tokio::pin!(stream); + + while let Some(res) = stream.next().await { + let entry = res?; + let in_path = entry.path(); + let out_path = prefix.join(entry.file_name()); + + let t = entry.file_type().await?; + + if t.is_file() || t.is_symlink() { + let data = fs::read(&in_path) + .await + .wrap_err_with(|| format!("Failed to read '{}'", in_path.display()))?; + { + let mut zip = zip.lock().await; + zip.start_file(out_path.to_slash_lossy(), Default::default())?; + zip.write_all(&data)?; + } + } else if t.is_dir() { + process_directory(zip.clone(), in_path, out_path).await?; + } + } + + Ok(()) } #[tracing::instrument(skip_all)] @@ -75,14 +106,23 @@ pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()> }; let data = Cursor::new(Vec::new()); - let mut zip = ZipWriter::new(data); + let zip = ZipWriter::new(data); + let zip = Arc::new(Mutex::new(zip)); - zip.add_directory(&cfg.id, Default::default())?; + let path = cfg.dir.join( + matches + .get_one::("directory") + .expect("parameter has default value"), + ); - let base_path = PathBuf::from(cfg.id); + process_directory(zip.clone(), path, PathBuf::from(&cfg.id)) + .await + .wrap_err("Failed to add directory to archive")?; + + let mut zip = zip.lock().await; { - let name = base_path.join("dtmt.cfg"); + let name = PathBuf::from(&cfg.id).join("dtmt.cfg"); let path = cfg.dir.join("dtmt.cfg"); let data = fs::read(&path) @@ -93,30 +133,6 @@ pub(crate) async fn run(_ctx: sdk::Context, matches: &ArgMatches) -> Result<()> zip.write_all(&data)?; } - { - let path = cfg.dir.join( - matches - .get_one::("directory") - .expect("parameter has default value"), - ); - let read_dir = fs::read_dir(&path) - .await - .wrap_err_with(|| format!("Failed to read directory '{}'", path.display()))?; - - let stream = ReadDirStream::new(read_dir) - .map(|res| res.wrap_err("Failed to read dir entry")) - .then(process_dir_entry); - tokio::pin!(stream); - - while let Some(res) = stream.next().await { - let (name, data) = res?; - - let name = base_path.join(name); - zip.start_file(name.to_slash_lossy(), Default::default())?; - zip.write_all(&data)?; - } - }; - let data = zip.finish()?; fs::write(&dest, data.into_inner()) diff --git a/lib/dtmt-shared/src/lib.rs b/lib/dtmt-shared/src/lib.rs index 17b8d92..d556fdf 100644 --- a/lib/dtmt-shared/src/lib.rs +++ b/lib/dtmt-shared/src/lib.rs @@ -33,12 +33,17 @@ pub enum ModDependency { #[derive(Clone, Debug, Default, Deserialize)] pub struct ModConfig { #[serde(skip)] - pub dir: std::path::PathBuf, + pub dir: PathBuf, pub id: String, pub name: String, - pub description: String, + pub summary: String, + pub description: Option, + pub author: Option, pub version: String, - pub packages: Vec, + pub image: Option, + #[serde(default)] + pub categories: Vec, + pub packages: Vec, pub resources: ModConfigResources, #[serde(default)] pub depends: Vec,