diff --git a/crates/dtmm/src/main_window.rs b/crates/dtmm/src/main_window.rs index 0284dd5..b8bf488 100644 --- a/crates/dtmm/src/main_window.rs +++ b/crates/dtmm/src/main_window.rs @@ -1,11 +1,14 @@ use druid::im::Vector; use druid::widget::{ Align, Button, CrossAxisAlignment, Flex, Label, List, MainAxisAlignment, Maybe, Scroll, Split, - ViewSwitcher, + TextBox, ViewSwitcher, }; use druid::{lens, Insets, LensExt, Widget, WidgetExt, WindowDesc}; -use crate::state::{ModInfo, State, View}; +use crate::state::{ + ModInfo, PathBufFormatter, State, StateController, View, ACTION_DELETE_SELECTED_MOD, + ACTION_SELECTED_MOD_DOWN, ACTION_SELECTED_MOD_UP, ACTION_SELECT_MOD, +}; use crate::theme; use crate::widget::ExtraWidgetExt; @@ -67,32 +70,21 @@ fn build_mod_list() -> impl Widget { 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!(); - }) + .with_child( + Label::dynamic(|enabled, _env| { + if *enabled { + "Enabled".into() + } else { + "Disabled".into() + } + }) + .lens(lens!((usize, ModInfo), 1).then(ModInfo::enabled)), + ) + .with_child(Label::raw().lens(lens!((usize, ModInfo), 1).then(ModInfo::name))) + .on_click(|ctx, (i, _info), _env| ctx.submit_notification(ACTION_SELECT_MOD.with(*i))) }); - Scroll::new(list) + let scroll = Scroll::new(list) .vertical() .lens(State::mods.map( |mods| { @@ -107,7 +99,12 @@ fn build_mod_list() -> impl Widget { }); }, )) - .content_must_fill() + .content_must_fill(); + + Flex::column() + .must_fill_main_axis(true) + .with_child(Flex::row()) + .with_flex_child(scroll, 1.0) } fn build_mod_details() -> impl Widget { @@ -120,23 +117,16 @@ fn build_mod_details() -> impl Widget { }, Flex::column, ) + .padding(Insets::uniform_xy(5.0, 1.0)) .lens(State::selected_mod); let button_move_up = Button::new("Move Up") - .on_click(|_ctx, index: &mut Option, _env| { - if let Some(i) = index.as_mut() { - *i = i.saturating_sub(1) - } - }) - .lens(State::selected_mod_index); + .on_click(|ctx, _state, _env| ctx.submit_notification(ACTION_SELECTED_MOD_UP)) + .disabled_if(|state: &State, _env: &druid::Env| !state.can_move_mod_up()); let button_move_down = Button::new("Move Down") - .on_click(|_ctx, index: &mut Option, _env| { - if let Some(i) = index.as_mut() { - *i = i.saturating_add(1) - } - }) - .lens(State::selected_mod_index); + .on_click(|ctx, _state, _env| ctx.submit_notification(ACTION_SELECTED_MOD_DOWN)) + .disabled_if(|state: &State, _env: &druid::Env| !state.can_move_mod_down()); let button_toggle_mod = Maybe::new( || { @@ -144,17 +134,18 @@ fn build_mod_details() -> impl Widget { if *enabled { "Disable Mod".into() } else { - "Enabled Mod".into() + "Enable Mod".into() } }) - .on_click(|_ctx, info: &mut bool, _env| { - *info = !*info; + .on_click(|_ctx, enabled: &mut bool, _env| { + *enabled = !(*enabled); }) .lens(ModInfo::enabled) }, // TODO: Gray out || Button::new("Enable Mod"), ) + .disabled_if(|info: &Option, _env: &druid::Env| info.is_none()) .lens(State::selected_mod); let button_add_mod = Button::new("Add Mod").on_click(|_ctx, state: &mut State, _env| { @@ -164,7 +155,9 @@ fn build_mod_details() -> impl Widget { }); let button_delete_mod = Button::new("Delete Mod") - .on_click(|_ctx, data: &mut State, _env| data.delete_selected_mod()); + .on_click(|ctx, _state, _env| ctx.submit_notification(ACTION_DELETE_SELECTED_MOD)) + .disabled_if(|info: &Option, _env: &druid::Env| info.is_none()) + .lens(State::selected_mod); let buttons = Flex::column() .with_child( @@ -204,7 +197,29 @@ fn build_view_mods() -> impl Widget { } fn build_view_settings() -> impl Widget { - Label::new("Settings") + let game_dir_setting = Flex::row() + .main_axis_alignment(MainAxisAlignment::Start) + .with_child(Label::new("Game Directory:")) + .with_default_spacer() + .with_child( + TextBox::new() + .with_formatter(PathBufFormatter::new()) + .lens(State::game_dir), + ); + let data_dir_setting = Flex::row() + .main_axis_alignment(MainAxisAlignment::Start) + .with_child(Label::new("Data Directory:")) + .with_default_spacer() + .with_child( + TextBox::new() + .with_formatter(PathBufFormatter::new()) + .lens(State::data_dir), + ); + + Flex::column() + .with_child(data_dir_setting) + .with_child(game_dir_setting) + .padding(Insets::uniform(5.0)) } fn build_view_about() -> impl Widget { @@ -233,4 +248,5 @@ fn build_window() -> impl Widget { .must_fill_main_axis(true) .with_child(build_top_bar()) .with_flex_child(build_main(), 1.0) + .controller(StateController::new()) } diff --git a/crates/dtmm/src/state.rs b/crates/dtmm/src/state.rs index 0639818..c22bdd7 100644 --- a/crates/dtmm/src/state.rs +++ b/crates/dtmm/src/state.rs @@ -1,9 +1,21 @@ +use std::path::PathBuf; use std::sync::Arc; use druid::im::Vector; -use druid::{Data, Lens}; +use druid::text::Formatter; +use druid::widget::Controller; +use druid::{ + AppDelegate, Data, DelegateCtx, Env, Event, EventCtx, Handled, Lens, Selector, Target, + Widget, +}; +use tokio::sync::mpsc::UnboundedSender; -#[derive(Copy, Clone, Data, PartialEq)] +pub const ACTION_SELECT_MOD: Selector = Selector::new("dtmm.action..select-mod"); +pub const ACTION_SELECTED_MOD_UP: Selector = Selector::new("dtmm.action.selected-mod-up"); +pub const ACTION_SELECTED_MOD_DOWN: Selector = Selector::new("dtmm.action.selected-mod-down"); +pub const ACTION_DELETE_SELECTED_MOD: Selector = Selector::new("dtmm.action.delete-selected-mod"); + +#[derive(Copy, Clone, Data, Debug, PartialEq)] pub(crate) enum View { Mods, Settings, @@ -16,20 +28,48 @@ impl Default for View { } } -#[derive(Clone, Data, Lens)] +#[derive(Clone, Data, Debug)] +pub struct PackageInfo { + name: String, + files: Vector, +} + +impl PackageInfo { + pub fn get_name(&self) -> &String { + &self.name + } + + pub fn get_files(&self) -> &Vector { + &self.files + } +} + +#[derive(Clone, Data, Debug, Lens)] pub(crate) struct ModInfo { name: String, description: Arc, enabled: bool, + #[lens(ignore)] + packages: Vector, } + 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, + packages: Vector::new(), } } + + pub fn get_packages(&self) -> &Vector { + &self.packages + } + + pub(crate) fn get_name(&self) -> &String { + &self.name + } } impl PartialEq for ModInfo { @@ -38,54 +78,15 @@ impl PartialEq for ModInfo { } } -#[derive(Clone, Data, Default, Lens)] +#[derive(Clone, Data, Lens)] pub(crate) struct State { current_view: View, mods: Vector, selected_mod_index: Option, -} - -pub(crate) struct SelectedModLens; - -impl Lens> for SelectedModLens { - fn with) -> 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>(&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` to `im::Vector<(usize, T)>`, -/// where each element in the destination vector includes its index in the -/// source vector. -pub(crate) struct IndexedVectorLens; - -impl Lens, Vector<(usize, T)>> for IndexedVectorLens { - fn with) -> V>(&self, data: &Vector, f: F) -> V { - let data = data - .iter() - .enumerate() - .map(|(i, val)| (i, val.clone())) - .collect(); - f(&data) - } - - fn with_mut) -> V>( - &self, - data: &mut Vector, - f: F, - ) -> V { - todo!() - } + is_deployment_in_progress: bool, + game_dir: Arc, + data_dir: Arc, + ctx: Arc, } impl State { @@ -93,7 +94,26 @@ impl State { pub const selected_mod: SelectedModLens = SelectedModLens; pub fn new() -> Self { - Default::default() + let ctx = sdk::Context::new(); + + let (game_dir, data_dir) = if cfg!(debug_assertions) { + ( + std::env::current_dir().expect("PWD is borked").join("data"), + PathBuf::from("/tmp/dtmm"), + ) + } else { + (PathBuf::new(), PathBuf::new()) + }; + + Self { + ctx: Arc::new(ctx), + current_view: View::default(), + mods: Vector::new(), + selected_mod_index: None, + is_deployment_in_progress: false, + game_dir: Arc::new(game_dir), + data_dir: Arc::new(data_dir), + } } pub fn get_current_view(&self) -> View { @@ -104,16 +124,203 @@ impl State { self.current_view = view; } - pub fn delete_selected_mod(&mut self) { - let Some(index) = self.selected_mod_index else { - return; - }; + pub fn get_mods(&self) -> Vector { + self.mods.clone() + } - self.mods.remove(index); + pub fn select_mod(&mut self, index: usize) { + self.selected_mod_index = Some(index); } pub fn add_mod(&mut self, info: ModInfo) { self.mods.push_back(info); self.selected_mod_index = Some(self.mods.len() - 1); } + + pub fn can_move_mod_down(&self) -> bool { + self.selected_mod_index + .map(|i| i < (self.mods.len().saturating_sub(1))) + .unwrap_or(false) + } + + pub fn can_move_mod_up(&self) -> bool { + self.selected_mod_index.map(|i| i > 0).unwrap_or(false) + } + + pub(crate) fn get_game_dir(&self) -> &PathBuf { + &self.game_dir + } + + pub(crate) fn get_mod_dir(&self) -> PathBuf { + self.data_dir.join("mods") + } + + pub(crate) fn get_ctx(&self) -> Arc { + self.ctx.clone() + } +} + +pub(crate) struct SelectedModLens; + +impl Lens> for SelectedModLens { + #[tracing::instrument(name = "SelectedModLens::with", skip_all)] + fn with) -> V>(&self, data: &State, f: F) -> V { + let info = data + .selected_mod_index + .and_then(|i| data.mods.get(i).cloned()); + + f(&info) + } + + #[tracing::instrument(name = "SelectedModLens::with_mut", skip_all)] + fn with_mut) -> V>(&self, data: &mut State, f: F) -> V { + match data.selected_mod_index { + Some(i) => { + let mut info = data.mods.get_mut(i).cloned(); + let ret = f(&mut info); + + if let Some(info) = info { + // TODO: Figure out a way to check for equality and + // only update when needed + data.mods.set(i, info); + } else { + data.selected_mod_index = None; + } + + ret + } + None => f(&mut None), + } + } +} + +/// A Lens that maps an `im::Vector` to `im::Vector<(usize, T)>`, +/// where each element in the destination vector includes its index in the +/// source vector. +pub(crate) struct IndexedVectorLens; + +impl Lens, Vector<(usize, T)>> for IndexedVectorLens { + #[tracing::instrument(name = "IndexedVectorLens::with", skip_all)] + fn with) -> V>(&self, values: &Vector, f: F) -> V { + let indexed = values + .iter() + .enumerate() + .map(|(i, val)| (i, val.clone())) + .collect(); + f(&indexed) + } + + #[tracing::instrument(name = "IndexedVectorLens::with_mut", skip_all)] + fn with_mut) -> V>( + &self, + values: &mut Vector, + f: F, + ) -> V { + let mut indexed = values + .iter() + .enumerate() + .map(|(i, val)| (i, val.clone())) + .collect(); + let ret = f(&mut indexed); + + *values = indexed.into_iter().map(|(_i, val)| val).collect(); + + ret + } +} + +pub struct StateController {} + +impl StateController { + pub fn new() -> Self { + Self {} + } +} + +// TODO: Turn notifications into commands on the AppDelegate +impl> Controller for StateController { + #[tracing::instrument(name = "StateController::event", skip_all)] + fn event( + &mut self, + child: &mut W, + ctx: &mut EventCtx, + event: &Event, + state: &mut State, + env: &Env, + ) { + match event { + Event::Notification(notif) if notif.is(ACTION_SELECT_MOD) => { + ctx.set_handled(); + let index = notif + .get(ACTION_SELECT_MOD) + .expect("notification type didn't match after check"); + + state.select_mod(*index); + } + Event::Notification(notif) if notif.is(ACTION_SELECTED_MOD_UP) => { + ctx.set_handled(); + let Some(i) = state.selected_mod_index else { + return; + }; + + let len = state.mods.len(); + if len == 0 || i == 0 { + return; + } + + state.mods.swap(i, i - 1); + state.selected_mod_index = Some(i - 1); + } + Event::Notification(notif) if notif.is(ACTION_SELECTED_MOD_DOWN) => { + ctx.set_handled(); + let Some(i) = state.selected_mod_index else { + return; + }; + + let len = state.mods.len(); + if len == 0 || i == usize::MAX || i >= len - 1 { + return; + } + + state.mods.swap(i, i + 1); + state.selected_mod_index = Some(i + 1); + } + Event::Notification(notif) if notif.is(ACTION_DELETE_SELECTED_MOD) => { + ctx.set_handled(); + let Some(index) = state.selected_mod_index else { + return; + }; + + state.mods.remove(index); + } + _ => child.event(ctx, event, state, env), + } + } +} + +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)) + } }