feat(dtmm): Create initial mod manager window
This commit is contained in:
parent
5eebced362
commit
50569a3c56
10 changed files with 1633 additions and 0 deletions
1079
Cargo.lock
generated
1079
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
19
crates/dtmm/Cargo.toml
Normal file
19
crates/dtmm/Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "dtmm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.0.15", features = ["color", "derive", "std", "cargo", "unicode"] }
|
||||
color-eyre = "0.6.2"
|
||||
confy = "0.5.1"
|
||||
druid = { git = "https://github.com/linebender/druid.git", features = ["im"] }
|
||||
sdk = { path = "../../lib/sdk", version = "0.2.0" }
|
||||
serde = "1.0.152"
|
||||
tokio = "1.23.0"
|
||||
toml = "0.5.10"
|
||||
tracing = "0.1.37"
|
||||
tracing-error = "0.2.0"
|
||||
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
|
49
crates/dtmm/notes.adoc
Normal file
49
crates/dtmm/notes.adoc
Normal file
|
@ -0,0 +1,49 @@
|
|||
= Notes
|
||||
|
||||
== Layout
|
||||
|
||||
- top bar:
|
||||
- left aligned: a tab bar with "mods", "settings", "about"
|
||||
- right aligned: a button to start the game
|
||||
- in the future: center aligned a dropdown to select profiles, and button to edit them
|
||||
- main view:
|
||||
- left side: list view of mods
|
||||
- right side: details pane and buttons
|
||||
- always visible, first mod in list is selected by default
|
||||
- buttons:
|
||||
- add mod
|
||||
- deploy mods
|
||||
- remove selected mod
|
||||
- enable/disable (text changes based on state)
|
||||
|
||||
== Mod list
|
||||
|
||||
- name
|
||||
- description?
|
||||
- image?
|
||||
- click to get details pane?
|
||||
|
||||
== Managing mods
|
||||
|
||||
- for each mod in the list, have a checkbox
|
||||
- need a button to remove mods
|
||||
- need a button to add mods from downloaded files
|
||||
- search
|
||||
|
||||
== Misc
|
||||
|
||||
- settings
|
||||
- open mod storage
|
||||
|
||||
== Managing the game
|
||||
|
||||
- deploy mods
|
||||
-
|
||||
|
||||
== Preparing the game
|
||||
|
||||
- click "Install mods" to prepare the game files with the enabled mods
|
||||
|
||||
== Playing the game
|
||||
|
||||
- if overlay file systems are used, the game has to be started through the mod manager
|
42
crates/dtmm/src/main.rs
Normal file
42
crates/dtmm/src/main.rs
Normal file
|
@ -0,0 +1,42 @@
|
|||
use clap::command;
|
||||
use color_eyre::Report;
|
||||
use color_eyre::Result;
|
||||
use druid::AppLauncher;
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::state::State;
|
||||
|
||||
mod main_window;
|
||||
mod state;
|
||||
mod theme;
|
||||
mod widget;
|
||||
|
||||
#[tracing::instrument]
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
let _matches = command!().get_matches();
|
||||
|
||||
{
|
||||
let fmt_layer = tracing_subscriber::fmt::layer().pretty();
|
||||
let filter_layer =
|
||||
EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("info"))?;
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(fmt_layer)
|
||||
.with(ErrorLayer::new(
|
||||
tracing_subscriber::fmt::format::Pretty::default(),
|
||||
))
|
||||
.init();
|
||||
}
|
||||
|
||||
let initial_state = State::new();
|
||||
|
||||
AppLauncher::with_window(main_window::new())
|
||||
.launch(initial_state)
|
||||
.map_err(Report::new)
|
||||
}
|
237
crates/dtmm/src/main_window.rs
Normal file
237
crates/dtmm/src/main_window.rs
Normal file
|
@ -0,0 +1,237 @@
|
|||
use druid::im::Vector;
|
||||
use druid::widget::{
|
||||
Align, Button, CrossAxisAlignment, Flex, Label, List, MainAxisAlignment, Maybe, Scroll, Split,
|
||||
ViewSwitcher,
|
||||
};
|
||||
use druid::{lens, Insets, LensExt, Widget, WidgetExt, WindowDesc};
|
||||
|
||||
use crate::state::{ModInfo, State, View};
|
||||
use crate::theme;
|
||||
use crate::widget::ExtraWidgetExt;
|
||||
|
||||
const TITLE: &str = "Darktide Mod Manager";
|
||||
const WINDOW_WIDTH: f64 = 800.0;
|
||||
const WINDOW_HEIGHT: f64 = 600.0;
|
||||
const MOD_DETAILS_MIN_WIDTH: f64 = 325.0;
|
||||
|
||||
pub(crate) fn new() -> WindowDesc<State> {
|
||||
WindowDesc::new(build_window())
|
||||
.title(TITLE)
|
||||
.window_size((WINDOW_WIDTH, WINDOW_HEIGHT))
|
||||
}
|
||||
|
||||
fn build_top_bar() -> impl Widget<State> {
|
||||
Flex::row()
|
||||
.must_fill_main_axis(true)
|
||||
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Button::new("Mods").on_click(|_ctx, state: &mut State, _env| {
|
||||
state.set_current_view(View::Mods)
|
||||
}),
|
||||
)
|
||||
.with_default_spacer()
|
||||
.with_child(
|
||||
Button::new("Settings").on_click(|_ctx, state: &mut State, _env| {
|
||||
state.set_current_view(View::Settings)
|
||||
}),
|
||||
)
|
||||
.with_default_spacer()
|
||||
.with_child(
|
||||
Button::new("About").on_click(|_ctx, state: &mut State, _env| {
|
||||
state.set_current_view(View::About)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(Button::new("Deploy Mods").on_click(
|
||||
|_ctx, _state: &mut State, _env| {
|
||||
todo!();
|
||||
},
|
||||
))
|
||||
.with_default_spacer()
|
||||
.with_child(
|
||||
Button::new("Run Game").on_click(|_ctx, _state: &mut State, _env| {
|
||||
todo!();
|
||||
}),
|
||||
),
|
||||
)
|
||||
.padding(theme::TOP_BAR_INSETS)
|
||||
.background(theme::TOP_BAR_BACKGROUND_COLOR)
|
||||
// TODO: Add bottom border. Need a custom widget for that, as the built-in only provides
|
||||
// uniform borders on all sides
|
||||
}
|
||||
|
||||
fn build_mod_list() -> impl Widget<State> {
|
||||
let list = List::new(|| {
|
||||
Flex::row()
|
||||
.must_fill_main_axis(true)
|
||||
// .with_child(
|
||||
// Label::dynamic(|enabled, _env| {
|
||||
// if *enabled {
|
||||
// "Enabled".into()
|
||||
// } else {
|
||||
// "Disabled".into()
|
||||
// }
|
||||
// })
|
||||
// .lens(
|
||||
// lens::Identity
|
||||
// .map(
|
||||
// |(i, info)| info,
|
||||
// |(i, info), new_info| {
|
||||
// todo!();
|
||||
// },
|
||||
// )
|
||||
// .then(ModInfo::enabled),
|
||||
// ),
|
||||
// )
|
||||
// .with_child(Label::raw().lens(ModInfo::name))
|
||||
.on_click(|_ctx, state, _env| {
|
||||
todo!();
|
||||
})
|
||||
});
|
||||
|
||||
Scroll::new(list)
|
||||
.vertical()
|
||||
.lens(State::mods.map(
|
||||
|mods| {
|
||||
mods.iter()
|
||||
.enumerate()
|
||||
.map(|(i, val)| (i, val.clone()))
|
||||
.collect::<Vector<_>>()
|
||||
},
|
||||
|mods, infos| {
|
||||
infos.into_iter().for_each(|(i, info)| {
|
||||
mods.set(i, info);
|
||||
});
|
||||
},
|
||||
))
|
||||
.content_must_fill()
|
||||
}
|
||||
|
||||
fn build_mod_details() -> impl Widget<State> {
|
||||
let details_container = Maybe::new(
|
||||
|| {
|
||||
Flex::column()
|
||||
.cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(Label::raw().lens(ModInfo::name))
|
||||
.with_flex_child(Label::raw().lens(ModInfo::description), 1.0)
|
||||
},
|
||||
Flex::column,
|
||||
)
|
||||
.lens(State::selected_mod);
|
||||
|
||||
let button_move_up = Button::new("Move Up")
|
||||
.on_click(|_ctx, index: &mut Option<usize>, _env| {
|
||||
if let Some(i) = index.as_mut() {
|
||||
*i = i.saturating_sub(1)
|
||||
}
|
||||
})
|
||||
.lens(State::selected_mod_index);
|
||||
|
||||
let button_move_down = Button::new("Move Down")
|
||||
.on_click(|_ctx, index: &mut Option<usize>, _env| {
|
||||
if let Some(i) = index.as_mut() {
|
||||
*i = i.saturating_add(1)
|
||||
}
|
||||
})
|
||||
.lens(State::selected_mod_index);
|
||||
|
||||
let button_toggle_mod = Maybe::new(
|
||||
|| {
|
||||
Button::dynamic(|enabled, _env| {
|
||||
if *enabled {
|
||||
"Disable Mod".into()
|
||||
} else {
|
||||
"Enabled Mod".into()
|
||||
}
|
||||
})
|
||||
.on_click(|_ctx, info: &mut bool, _env| {
|
||||
*info = !*info;
|
||||
})
|
||||
.lens(ModInfo::enabled)
|
||||
},
|
||||
// TODO: Gray out
|
||||
|| Button::new("Enable Mod"),
|
||||
)
|
||||
.lens(State::selected_mod);
|
||||
|
||||
let button_add_mod = Button::new("Add Mod").on_click(|_ctx, state: &mut State, _env| {
|
||||
// TODO: Implement properly
|
||||
let info = ModInfo::new();
|
||||
state.add_mod(info);
|
||||
});
|
||||
|
||||
let button_delete_mod = Button::new("Delete Mod")
|
||||
.on_click(|_ctx, data: &mut State, _env| data.delete_selected_mod());
|
||||
|
||||
let buttons = Flex::column()
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_child(button_move_up)
|
||||
.with_default_spacer()
|
||||
.with_child(button_move_down)
|
||||
.padding(Insets::uniform_xy(5.0, 2.0)),
|
||||
)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_child(button_toggle_mod)
|
||||
.with_default_spacer()
|
||||
.with_child(button_add_mod)
|
||||
.with_default_spacer()
|
||||
.with_child(button_delete_mod)
|
||||
.padding(Insets::uniform_xy(5.0, 2.0)),
|
||||
)
|
||||
.with_default_spacer();
|
||||
|
||||
Flex::column()
|
||||
.must_fill_main_axis(true)
|
||||
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_flex_child(details_container, 1.0)
|
||||
.with_child(buttons)
|
||||
}
|
||||
|
||||
fn build_view_mods() -> impl Widget<State> {
|
||||
Split::columns(build_mod_list(), build_mod_details())
|
||||
.split_point(0.75)
|
||||
.min_size(0.0, MOD_DETAILS_MIN_WIDTH)
|
||||
.solid_bar(true)
|
||||
.bar_size(2.0)
|
||||
.draggable(true)
|
||||
}
|
||||
|
||||
fn build_view_settings() -> impl Widget<State> {
|
||||
Label::new("Settings")
|
||||
}
|
||||
|
||||
fn build_view_about() -> impl Widget<State> {
|
||||
Align::centered(
|
||||
Flex::column()
|
||||
.with_child(Label::new("Darktide Mod Manager"))
|
||||
.with_child(Label::new(
|
||||
"Website: https://git.sclu1034.dev/bitsquid_dt/dtmt",
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_main() -> impl Widget<State> {
|
||||
ViewSwitcher::new(
|
||||
|state: &State, _env| state.get_current_view(),
|
||||
|selector, _state, _env| match selector {
|
||||
View::Mods => Box::new(build_view_mods()),
|
||||
View::Settings => Box::new(build_view_settings()),
|
||||
View::About => Box::new(build_view_about()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn build_window() -> impl Widget<State> {
|
||||
Flex::column()
|
||||
.must_fill_main_axis(true)
|
||||
.with_child(build_top_bar())
|
||||
.with_flex_child(build_main(), 1.0)
|
||||
}
|
119
crates/dtmm/src/state.rs
Normal file
119
crates/dtmm/src/state.rs
Normal file
|
@ -0,0 +1,119 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use druid::im::Vector;
|
||||
use druid::{Data, Lens};
|
||||
|
||||
#[derive(Copy, Clone, Data, PartialEq)]
|
||||
pub(crate) enum View {
|
||||
Mods,
|
||||
Settings,
|
||||
About,
|
||||
}
|
||||
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
Self::Mods
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Data, Lens)]
|
||||
pub(crate) struct ModInfo {
|
||||
name: String,
|
||||
description: Arc<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
impl ModInfo {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
name: format!("Test Mod: {:?}", std::time::SystemTime::now()),
|
||||
description: Arc::new(String::from("A test dummy")),
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ModInfo {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.name.eq(&other.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Data, Default, Lens)]
|
||||
pub(crate) struct State {
|
||||
current_view: View,
|
||||
mods: Vector<ModInfo>,
|
||||
selected_mod_index: Option<usize>,
|
||||
}
|
||||
|
||||
pub(crate) struct SelectedModLens;
|
||||
|
||||
impl Lens<State, Option<ModInfo>> for SelectedModLens {
|
||||
fn with<V, F: FnOnce(&Option<ModInfo>) -> V>(&self, data: &State, f: F) -> V {
|
||||
let info = data
|
||||
.selected_mod_index
|
||||
.and_then(|i| data.mods.get(i).cloned());
|
||||
|
||||
f(&info)
|
||||
}
|
||||
|
||||
fn with_mut<V, F: FnOnce(&mut Option<ModInfo>) -> V>(&self, data: &mut State, f: F) -> V {
|
||||
let mut info = data
|
||||
.selected_mod_index
|
||||
.and_then(|i| data.mods.get_mut(i).cloned());
|
||||
f(&mut info)
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lens that maps an `im::Vector<T>` to `im::Vector<(usize, T)>`,
|
||||
/// where each element in the destination vector includes its index in the
|
||||
/// source vector.
|
||||
pub(crate) struct IndexedVectorLens;
|
||||
|
||||
impl<T: Data> Lens<Vector<T>, Vector<(usize, T)>> for IndexedVectorLens {
|
||||
fn with<V, F: FnOnce(&Vector<(usize, T)>) -> V>(&self, data: &Vector<T>, f: F) -> V {
|
||||
let data = data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, val)| (i, val.clone()))
|
||||
.collect();
|
||||
f(&data)
|
||||
}
|
||||
|
||||
fn with_mut<V, F: FnOnce(&mut Vector<(usize, T)>) -> V>(
|
||||
&self,
|
||||
data: &mut Vector<T>,
|
||||
f: F,
|
||||
) -> V {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
#[allow(non_upper_case_globals)]
|
||||
pub const selected_mod: SelectedModLens = SelectedModLens;
|
||||
|
||||
pub fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn get_current_view(&self) -> View {
|
||||
self.current_view
|
||||
}
|
||||
|
||||
pub fn set_current_view(&mut self, view: View) {
|
||||
self.current_view = view;
|
||||
}
|
||||
|
||||
pub fn delete_selected_mod(&mut self) {
|
||||
let Some(index) = self.selected_mod_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.mods.remove(index);
|
||||
}
|
||||
|
||||
pub fn add_mod(&mut self, info: ModInfo) {
|
||||
self.mods.push_back(info);
|
||||
self.selected_mod_index = Some(self.mods.len() - 1);
|
||||
}
|
||||
}
|
4
crates/dtmm/src/theme.rs
Normal file
4
crates/dtmm/src/theme.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
use druid::{Color, Insets};
|
||||
|
||||
pub const TOP_BAR_BACKGROUND_COLOR: Color = Color::rgba8(255, 255, 255, 50);
|
||||
pub const TOP_BAR_INSETS: Insets = Insets::uniform(5.0);
|
7
crates/dtmm/src/widget/container.rs
Normal file
7
crates/dtmm/src/widget/container.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
use druid::{Data, Widget, WidgetPod};
|
||||
|
||||
pub struct Container<T> {
|
||||
child: WidgetPod<T, Box<dyn Widget<T>>>,
|
||||
}
|
||||
|
||||
impl<T: Data> Container<T> {}
|
63
crates/dtmm/src/widget/fill_container.rs
Normal file
63
crates/dtmm/src/widget/fill_container.rs
Normal file
|
@ -0,0 +1,63 @@
|
|||
use std::f64::INFINITY;
|
||||
|
||||
use druid::widget::prelude::*;
|
||||
use druid::{Point, WidgetPod};
|
||||
|
||||
pub struct FillContainer<T> {
|
||||
child: WidgetPod<T, Box<dyn Widget<T>>>,
|
||||
}
|
||||
|
||||
impl<T: Data> FillContainer<T> {
|
||||
pub fn new(child: impl Widget<T> + 'static) -> Self {
|
||||
Self {
|
||||
child: WidgetPod::new(child).boxed(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Data> Widget<T> for FillContainer<T> {
|
||||
#[tracing::instrument(name = "FillContainer", level = "trace", skip_all)]
|
||||
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {
|
||||
self.child.event(ctx, event, data, env);
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "FillContainer", level = "trace", skip_all)]
|
||||
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) {
|
||||
self.child.lifecycle(ctx, event, data, env)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "FillContainer", level = "trace", skip_all)]
|
||||
fn update(&mut self, ctx: &mut UpdateCtx, _: &T, data: &T, env: &Env) {
|
||||
self.child.update(ctx, data, env);
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "FillContainer", level = "trace", skip_all)]
|
||||
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {
|
||||
bc.debug_check("FillContainer");
|
||||
|
||||
let child_size = self.child.layout(ctx, bc, data, env);
|
||||
|
||||
let w = if bc.is_width_bounded() {
|
||||
INFINITY
|
||||
} else {
|
||||
child_size.width
|
||||
};
|
||||
|
||||
let h = if bc.is_height_bounded() {
|
||||
INFINITY
|
||||
} else {
|
||||
child_size.height
|
||||
};
|
||||
|
||||
let my_size = bc.constrain(Size::new(w, h));
|
||||
|
||||
self.child.set_origin(ctx, Point::new(0.0, 0.0));
|
||||
tracing::trace!("Computed layout: size={}", my_size);
|
||||
my_size
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "FillContainer", level = "trace", skip_all)]
|
||||
fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {
|
||||
self.child.paint(ctx, data, env);
|
||||
}
|
||||
}
|
14
crates/dtmm/src/widget/mod.rs
Normal file
14
crates/dtmm/src/widget/mod.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
use druid::{Data, Widget};
|
||||
|
||||
use self::fill_container::FillContainer;
|
||||
|
||||
pub mod container;
|
||||
pub mod fill_container;
|
||||
|
||||
pub trait ExtraWidgetExt<T: Data>: Widget<T> + Sized + 'static {
|
||||
fn content_must_fill(self) -> FillContainer<T> {
|
||||
FillContainer::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Data, W: Widget<T> + 'static> ExtraWidgetExt<T> for W {}
|
Loading…
Add table
Reference in a new issue