diff --git a/Cargo.lock b/Cargo.lock index 62f72e8..0b6b910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -984,6 +984,14 @@ dependencies = [ "profiling", ] +[[package]] +name = "egui-modal" +version = "0.6.0" +dependencies = [ + "eframe", + "egui", +] + [[package]] name = "egui-wgpu" version = "0.31.1" @@ -3245,10 +3253,12 @@ dependencies = [ "dirs", "eframe", "egui", + "egui-modal", "egui_alignments", "env_logger", "hex", "keyvalues-parser", + "log", "regex", "registry", "rfd", diff --git a/Cargo.toml b/Cargo.toml index 6e12847..75d84b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,4 +19,8 @@ regex = "1.11.1" sha2 = "0.10.8" hex = "0.4.3" egui_alignments = "0.3.4" +log = "0.4.27" +egui-modal = { path = "./egui-modal" } +[workspace] +members = ["egui-modal"] \ No newline at end of file diff --git a/egui-modal/.gitignore b/egui-modal/.gitignore new file mode 100644 index 0000000..3c06f81 --- /dev/null +++ b/egui-modal/.gitignore @@ -0,0 +1,12 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +# Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +Cargo.lock \ No newline at end of file diff --git a/egui-modal/Cargo.toml b/egui-modal/Cargo.toml new file mode 100644 index 0000000..e5613d7 --- /dev/null +++ b/egui-modal/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "egui-modal" +version = "0.6.0" +edition = "2021" +license = "MIT" +description = "a modal library for egui" +repository = "https://github.com/n00kii/egui-modal" +readme = "README.md" +authors = ["n00kii"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +egui = { version = "0.31.1", default-features = false } + +[dev-dependencies] +eframe = "0.31.1" diff --git a/egui-modal/LICENSE b/egui-modal/LICENSE new file mode 100644 index 0000000..08cc294 --- /dev/null +++ b/egui-modal/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 n00kii + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/egui-modal/src/lib.rs b/egui-modal/src/lib.rs new file mode 100644 index 0000000..cfa426b --- /dev/null +++ b/egui-modal/src/lib.rs @@ -0,0 +1,5 @@ +#![warn(missing_docs)] +//! egui-modal +//! Modal library for [`egui`] +mod modal; +pub use modal::*; diff --git a/egui-modal/src/modal.rs b/egui-modal/src/modal.rs new file mode 100644 index 0000000..9c85c2b --- /dev/null +++ b/egui-modal/src/modal.rs @@ -0,0 +1,629 @@ +use egui::{ + emath::{Align, Align2}, epaint::{Color32, Pos2}, Area, Button, Context, CornerRadius, Id, Layout, Response, RichText, Sense, Ui, WidgetText, Window +}; + +const ERROR_ICON_COLOR: Color32 = Color32::from_rgb(200, 90, 90); +const INFO_ICON_COLOR: Color32 = Color32::from_rgb(150, 200, 210); +const WARNING_ICON_COLOR: Color32 = Color32::from_rgb(230, 220, 140); +const SUCCESS_ICON_COLOR: Color32 = Color32::from_rgb(140, 230, 140); + +const CAUTION_BUTTON_FILL: Color32 = Color32::from_rgb(87, 38, 34); +const SUGGESTED_BUTTON_FILL: Color32 = Color32::from_rgb(33, 54, 84); +const CAUTION_BUTTON_TEXT_COLOR: Color32 = Color32::from_rgb(242, 148, 148); +const SUGGESTED_BUTTON_TEXT_COLOR: Color32 = Color32::from_rgb(141, 182, 242); + +const OVERLAY_COLOR: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 200); + +/// The different styles a modal button can take. +pub enum ModalButtonStyle { + /// A normal [`egui`] button + None, + /// A button highlighted blue + Suggested, + /// A button highlighted red + Caution, +} + +/// An icon. If used, it will be shown next to the body of +/// the modal. +#[derive(Clone, Default, PartialEq)] +pub enum Icon { + #[default] + /// An info icon + Info, + /// A warning icon + Warning, + /// A success icon + Success, + /// An error icon + Error, + /// A custom icon. The first field in the tuple is + /// the text of the icon, and the second field is the + /// color. + Custom((String, Color32)), +} + +impl std::fmt::Display for Icon { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Icon::Info => write!(f, "ℹ"), + Icon::Warning => write!(f, "⚠"), + Icon::Success => write!(f, "✔"), + Icon::Error => write!(f, "❗"), + Icon::Custom((icon_text, _)) => write!(f, "{icon_text}"), + } + } +} + +#[derive(Clone, Default)] +struct DialogData { + title: Option, + body: Option, + icon: Option, +} + +/// Used for constructing and opening a modal dialog. This can be used +/// to both set the title/body/icon of the modal and open it as a one-time call +/// (as opposed to a continous call in the update loop) at the same time. +/// Make sure to call `DialogBuilder::open` to actually open the dialog. +#[must_use = "use `DialogBuilder::open`"] +pub struct DialogBuilder { + data: DialogData, + modal_id: Id, + ctx: Context, +} + +#[derive(Clone)] +enum ModalType { + Modal, + Dialog(DialogData), +} + +#[derive(Clone)] +/// Information about the current state of the modal. (Pretty empty +/// right now but may be expanded upon in the future.) +struct ModalState { + is_open: bool, + was_outside_clicked: bool, + modal_type: ModalType, + last_frame_height: Option, +} + +#[derive(Clone, Debug)] +/// Contains styling parameters for the modal, like body margin +/// and button colors. +pub struct ModalStyle { + /// The margin around the modal body. Only applies if using + /// [`.body()`] + pub body_margin: f32, + /// The margin around the container of the icon and body. Only + /// applies if using [`.frame()`] + pub frame_margin: f32, + /// The margin around the container of the icon. Only applies + /// if using [`.icon()`]. + pub icon_margin: f32, + /// The size of any icons used in the modal + pub icon_size: f32, + /// The color of the overlay that dims the background + pub overlay_color: Color32, + + /// The fill color for the caution button style + pub caution_button_fill: Color32, + /// The fill color for the suggested button style + pub suggested_button_fill: Color32, + + /// The text color for the caution button style + pub caution_button_text_color: Color32, + /// The text color for the suggested button style + pub suggested_button_text_color: Color32, + + /// The text of the acknowledgement button for dialogs + pub dialog_ok_text: String, + + /// The color of the info icon + pub info_icon_color: Color32, + /// The color of the warning icon + pub warning_icon_color: Color32, + /// The color of the success icon + pub success_icon_color: Color32, + /// The color of the error icon + pub error_icon_color: Color32, + + /// The default width of the modal + pub default_width: Option, + /// The default height of the modal + pub default_height: Option, + + /// The alignment of text inside the body + pub body_alignment: Align, +} + +impl ModalState { + fn load(ctx: &Context, id: Id) -> Self { + ctx.data_mut(|d| d.get_temp(id).unwrap_or_default()) + } + fn save(self, ctx: &Context, id: Id) { + ctx.data_mut(|d| d.insert_temp(id, self)) + } +} + +impl Default for ModalState { + fn default() -> Self { + Self { + was_outside_clicked: false, + is_open: false, + modal_type: ModalType::Modal, + last_frame_height: None, + } + } +} + +impl Default for ModalStyle { + fn default() -> Self { + Self { + body_margin: 5., + icon_margin: 7., + frame_margin: 2., + icon_size: 30., + overlay_color: OVERLAY_COLOR, + + caution_button_fill: CAUTION_BUTTON_FILL, + suggested_button_fill: SUGGESTED_BUTTON_FILL, + + caution_button_text_color: CAUTION_BUTTON_TEXT_COLOR, + suggested_button_text_color: SUGGESTED_BUTTON_TEXT_COLOR, + + dialog_ok_text: "ok".to_string(), + + info_icon_color: INFO_ICON_COLOR, + warning_icon_color: WARNING_ICON_COLOR, + success_icon_color: SUCCESS_ICON_COLOR, + error_icon_color: ERROR_ICON_COLOR, + + default_height: None, + default_width: None, + + body_alignment: Align::Min, + } + } +} +/// A [`Modal`] is created using [`Modal::new()`]. Make sure to use a `let` binding when +/// using [`Modal::new()`] to ensure you can call things like [`Modal::open()`] later on. +/// ``` +/// let modal = Modal::new(ctx, "my_modal"); +/// modal.show(|ui| { +/// ui.label("Hello world!") +/// }); +/// if ui.button("modal").clicked() { +/// modal.open(); +/// } +/// ``` +/// Helper functions are also available to use that help apply margins based on the modal's +/// [`ModalStyle`]. They are not necessary to use, but may help reduce boilerplate. +/// ``` +/// let other_modal = Modal::new(ctx, "another_modal"); +/// other_modal.show(|ui| { +/// other_modal.frame(ui, |ui| { +/// other_modal.body(ui, "Hello again, world!"); +/// }); +/// other_modal.buttons(ui, |ui| { +/// other_modal.button(ui, "Close"); +/// }); +/// }); +/// if ui.button("open the other modal").clicked() { +/// other_modal.open(); +/// } +/// ``` +pub struct Modal { + close_on_outside_click: bool, + style: ModalStyle, + ctx: Context, + id: Id, + window_id: Id, +} + +fn ui_with_margin(ui: &mut Ui, margin: f32, add_contents: impl FnOnce(&mut Ui) -> R) { + egui::Frame::NONE + .inner_margin(margin) + .show(ui, |ui| add_contents(ui)); +} + +impl Modal { + /// Creates a new [`Modal`]. Can use constructor functions like [`Modal::with_style`] + /// to modify upon creation. + pub fn new(ctx: &Context, id_source: impl std::fmt::Display) -> Self { + let self_id = Id::new(id_source.to_string()); + Self { + window_id: self_id.with("window"), + id: self_id, + style: ModalStyle::default(), + ctx: ctx.clone(), + close_on_outside_click: false, + } + } + + fn set_open_state(&self, is_open: bool) { + let mut modal_state = ModalState::load(&self.ctx, self.id); + modal_state.is_open = is_open; + modal_state.save(&self.ctx, self.id) + } + + fn set_outside_clicked(&self, was_clicked: bool) { + let mut modal_state = ModalState::load(&self.ctx, self.id); + modal_state.was_outside_clicked = was_clicked; + modal_state.save(&self.ctx, self.id) + } + + /// Was the outer overlay clicked this frame? + pub fn was_outside_clicked(&self) -> bool { + let modal_state = ModalState::load(&self.ctx, self.id); + modal_state.was_outside_clicked + } + + /// Is the modal currently open? + pub fn is_open(&self) -> bool { + let modal_state = ModalState::load(&self.ctx, self.id); + modal_state.is_open + } + + /// Open the modal; make it visible. The modal prevents user input to other parts of the + /// application. + /// + /// ⚠️ WARNING ⚠️: This function requires a write lock to the [`egui::Context`]. Using it within + /// closures within functions like [`egui::Ui::input_mut`] will result in a deadlock. [Tracking issue](https://github.com/n00kii/egui-modal/issues/15) + pub fn open(&self) { + self.set_open_state(true) + } + + /// Close the modal so that it is no longer visible, allowing input to flow back into + /// the application. + /// + /// ⚠️ WARNING ⚠️: This function requires a write lock to the [`egui::Context`]. Using it within + /// closures within functions like [`egui::Ui::input_mut`] will result in a deadlock. [Tracking issue](https://github.com/n00kii/egui-modal/issues/15) + pub fn close(&self) { + self.set_open_state(false) + } + + /// If set to `true`, the modal will close itself if the user clicks outside on the modal window + /// (onto the overlay). + pub fn with_close_on_outside_click(mut self, do_close_on_click_ouside: bool) -> Self { + self.close_on_outside_click = do_close_on_click_ouside; + self + } + + /// Change the [`ModalStyle`] of the modal upon creation. + pub fn with_style(mut self, style: &ModalStyle) -> Self { + self.style = style.clone(); + self + } + + /// Helper function for styling the title of the modal. + /// ``` + /// let modal = Modal::new(ctx, "modal"); + /// modal.show(|ui| { + /// modal.title(ui, "my title"); + /// }); + /// ``` + pub fn title(&self, ui: &mut Ui, text: impl Into) { + let text: RichText = text.into(); + ui.vertical_centered(|ui| { + ui.heading(text); + }); + ui.separator(); + } + + /// Helper function for styling the icon of the modal. + /// ``` + /// let modal = Modal::new(ctx, "modal"); + /// modal.show(|ui| { + /// modal.frame(ui, |ui| { + /// modal.icon(ui, Icon::Info); + /// }); + /// }); + /// ``` + pub fn icon(&self, ui: &mut Ui, icon: Icon) { + let color = match icon { + Icon::Info => self.style.info_icon_color, + Icon::Warning => self.style.warning_icon_color, + Icon::Success => self.style.success_icon_color, + Icon::Error => self.style.error_icon_color, + Icon::Custom((_, color)) => color, + }; + let text = RichText::new(icon.to_string()) + .color(color) + .size(self.style.icon_size); + ui_with_margin(ui, self.style.icon_margin, |ui| { + ui.add(egui::Label::new(text)); + }); + } + + /// Helper function for styling the container the of body and icon. + /// ``` + /// let modal = Modal::new(ctx, "modal"); + /// modal.show(|ui| { + /// modal.title(ui, "my title"); + /// modal.frame(ui, |ui| { + /// // inner modal contents go here + /// }); + /// modal.buttons(ui, |ui| { + /// // button contents go here + /// }); + /// }); + /// ``` + pub fn frame(&self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) { + let last_frame_height = ModalState::load(&self.ctx, self.id) + .last_frame_height + .unwrap_or_default(); + let default_height = self.style.default_height.unwrap_or_default(); + let space_height = ((default_height - last_frame_height) * 0.5).max(0.); + ui.with_layout( + Layout::top_down(Align::Center).with_cross_align(Align::Center), + |ui| { + ui_with_margin(ui, self.style.frame_margin, |ui| { + if space_height > 0. { + ui.add_space(space_height); + add_contents(ui); + ui.add_space(space_height); + } else { + add_contents(ui); + } + }) + }, + ); + } + + /// Helper function that should be used when using a body and icon together. + /// ``` + /// let modal = Modal::new(ctx, "modal"); + /// modal.show(|ui| { + /// modal.frame(ui, |ui| { + /// modal.body_and_icon(ui, "my modal body", Icon::Warning); + /// }); + /// }); + /// ``` + pub fn body_and_icon(&self, ui: &mut Ui, text: impl Into, icon: Icon) { + egui::Grid::new(self.id).num_columns(2).show(ui, |ui| { + self.icon(ui, icon); + self.body(ui, text); + }); + } + + /// Helper function for styling the body of the modal. + /// ``` + /// let modal = Modal::new(ctx, "modal"); + /// modal.show(|ui| { + /// modal.frame(ui, |ui| { + /// modal.body(ui, "my modal body"); + /// }); + /// }); + /// ``` + pub fn body(&self, ui: &mut Ui, text: impl Into) { + let text: WidgetText = text.into(); + ui.with_layout(Layout::top_down(self.style.body_alignment), |ui| { + ui_with_margin(ui, self.style.body_margin, |ui| { + ui.label(text); + }) + }); + } + + /// Helper function for styling the button container of the modal. + /// ``` + /// let modal = Modal::new(ctx, "modal"); + /// modal.show(|ui| { + /// modal.buttons(ui, |ui| { + /// modal.button(ui, "my modal button"); + /// }); + /// }); + /// ``` + pub fn buttons(&self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) { + ui.separator(); + ui.with_layout(Layout::right_to_left(Align::Min), add_contents); + } + + /// Helper function for creating a normal button for the modal. + /// Automatically closes the modal on click. + pub fn button(&self, ui: &mut Ui, text: impl Into) -> Response { + self.styled_button(ui, text, ModalButtonStyle::None) + } + + /// Helper function for creating a "cautioned" button for the modal. + /// Automatically closes the modal on click. + pub fn caution_button(&self, ui: &mut Ui, text: impl Into) -> Response { + self.styled_button(ui, text, ModalButtonStyle::Caution) + } + + /// Helper function for creating a "suggested" button for the modal. + /// Automatically closes the modal on click. + pub fn suggested_button(&self, ui: &mut Ui, text: impl Into) -> Response { + self.styled_button(ui, text, ModalButtonStyle::Suggested) + } + + fn styled_button( + &self, + ui: &mut Ui, + text: impl Into, + button_style: ModalButtonStyle, + ) -> Response { + let button = match button_style { + ModalButtonStyle::Suggested => { + let text: WidgetText = text.into().color(self.style.suggested_button_text_color); + Button::new(text).fill(self.style.suggested_button_fill) + } + ModalButtonStyle::Caution => { + let text: WidgetText = text.into().color(self.style.caution_button_text_color); + Button::new(text).fill(self.style.caution_button_fill) + } + ModalButtonStyle::None => Button::new(text.into()), + }; + + let response = ui.add(button); + if response.clicked() { + self.close() + } + response + } + + /// The ui contained in this function will be shown within the modal window. The modal will only actually show + /// when [`Modal::open`] is used. + pub fn show(&self, add_contents: impl FnOnce(&mut Ui) -> R) { + let mut modal_state = ModalState::load(&self.ctx, self.id); + self.set_outside_clicked(false); + if modal_state.is_open { + let ctx_clone = self.ctx.clone(); + let area_resp = Area::new(self.id) + .interactable(true) + .fixed_pos(Pos2::ZERO) + .show(&self.ctx, |ui: &mut Ui| { + let screen_rect = ui.ctx().input(|i| i.screen_rect); + let area_response = ui.allocate_response(screen_rect.size(), Sense::click()); + // let current_focus = area_response.ctx.memory().focus().clone(); + // let top_layer = area_response.ctx.memory().layer_ids().last(); + // if let Some(focus) = current_focus { + // area_response.ctx.memory().surrender_focus(focus) + // } + if area_response.clicked() { + self.set_outside_clicked(true); + if self.close_on_outside_click { + self.close(); + } + } + ui.painter() + .rect_filled(screen_rect, CornerRadius::ZERO, self.style.overlay_color); + }); + + ctx_clone.move_to_top(area_resp.response.layer_id); + + // the below lines of code addresses a weird problem where if the default_height changes, egui doesnt respond unless + // it's a different window id + let mut window_id = self + .style + .default_width + .map_or(self.window_id, |w| self.window_id.with(w.to_string())); + + window_id = self + .style + .default_height + .map_or(window_id, |h| window_id.with(h.to_string())); + + let mut window = Window::new("") + .id(window_id) + .open(&mut modal_state.is_open) + .title_bar(false) + .anchor(Align2::CENTER_CENTER, [0., 0.]) + .resizable(false); + + let recalculating_height = + self.style.default_height.is_some() && modal_state.last_frame_height.is_none(); + + if let Some(default_height) = self.style.default_height { + window = window.default_height(default_height); + } + + if let Some(default_width) = self.style.default_width { + window = window.default_width(default_width); + } + + let response = window.show(&ctx_clone, add_contents); + + if let Some(inner_response) = response { + ctx_clone.move_to_top(inner_response.response.layer_id); + if recalculating_height { + let mut modal_state = ModalState::load(&self.ctx, self.id); + modal_state.last_frame_height = Some(inner_response.response.rect.height()); + modal_state.save(&self.ctx, self.id); + } + } + } + } + + /// Open the modal as a dialog. This is a shorthand way of defining a [`Modal::show`] once, + /// for example, if a function returns an `Error`. This should be used in conjunction with + /// [`Modal::show_dialog`]. + #[deprecated(since = "0.3.0", note = "use `Modal::dialog`")] + pub fn open_dialog( + &self, + title: Option, + body: Option, + icon: Option, + ) { + let modal_data = DialogData { + title: title.map(|s| s.to_string()), + body: body.map(|s| s.to_string()), + icon, + }; + let mut modal_state = ModalState::load(&self.ctx, self.id); + modal_state.modal_type = ModalType::Dialog(modal_data); + modal_state.is_open = true; + modal_state.save(&self.ctx, self.id); + } + + /// Create a `DialogBuilder` for this modal. Make sure to use `DialogBuilder::open` + /// to open the dialog. + pub fn dialog(&self) -> DialogBuilder { + DialogBuilder { + data: DialogData::default(), + modal_id: self.id.clone(), + ctx: self.ctx.clone(), + } + } + + /// Needed in order to use [`Modal::dialog`]. Make sure this is called every frame, as + /// it renders the necessary ui when using a modal as a dialog. + pub fn show_dialog(&mut self) { + let modal_state = ModalState::load(&self.ctx, self.id); + if let ModalType::Dialog(modal_data) = modal_state.modal_type { + self.close_on_outside_click = true; + self.show(|ui| { + if let Some(title) = modal_data.title { + self.title(ui, title) + } + self.frame(ui, |ui| { + if modal_data.body.is_none() { + if let Some(icon) = modal_data.icon { + self.icon(ui, icon) + } + } else if modal_data.icon.is_none() { + if let Some(body) = modal_data.body { + self.body(ui, body) + } + } else if modal_data.icon.is_some() && modal_data.icon.is_some() { + self.body_and_icon(ui, modal_data.body.unwrap(), modal_data.icon.unwrap()) + } + }); + self.buttons(ui, |ui| { + ui.with_layout(Layout::top_down_justified(Align::Center), |ui| { + self.button(ui, &self.style.dialog_ok_text) + }) + }) + }); + } + } +} + +impl DialogBuilder { + /// Construct this dialog with the given title. + pub fn with_title(mut self, title: impl std::fmt::Display) -> Self { + self.data.title = Some(title.to_string()); + self + } + /// Construct this dialog with the given body. + pub fn with_body(mut self, body: impl std::fmt::Display) -> Self { + self.data.body = Some(body.to_string()); + self + } + /// Construct this dialog with the given icon. + pub fn with_icon(mut self, icon: Icon) -> Self { + self.data.icon = Some(icon); + self + } + /// Open the dialog. + /// + /// ⚠️ WARNING ⚠️: This function requires a write lock to the [`egui::Context`]. Using it within + /// closures within functions like [`egui::Ui::input_mut`] will result in a deadlock. [Tracking issue](https://github.com/n00kii/egui-modal/issues/15) + pub fn open(self) { + let mut modal_state = ModalState::load(&self.ctx, self.modal_id); + modal_state.modal_type = ModalType::Dialog(self.data); + modal_state.is_open = true; + modal_state.save(&self.ctx, self.modal_id); + } +} diff --git a/src/app_logic.rs b/src/app_logic.rs index 34dc46d..2f67275 100644 --- a/src/app_logic.rs +++ b/src/app_logic.rs @@ -1,14 +1,16 @@ -use std::{sync::Arc, thread, time::Duration}; +use std::{f32::consts::E, path::PathBuf, sync::Arc, thread, time::Duration}; use crossbeam_channel::Receiver; use egui::{mutex::Mutex}; +use log::debug; -use crate::{config, omori_locator}; +use crate::{config::{self, ConfigProvider}, omori_locator}; #[derive(Clone)] pub enum UiState { Loading, KeyRequired(String), + PickGame(Result, Vec), Error(String) } @@ -47,6 +49,19 @@ impl AppThread { } } + fn pick_game(&mut self, provider: &ConfigProvider) -> anyhow::Result<()> { + let steam_location = match omori_locator::get_omori_path() { + Ok(l) => Ok(l), + Err(e) => Err(String::from(format!("{:#}", e))) + }; + + let location_history = provider.get_game_paths()?; + + self.commit(UiState::PickGame(steam_location.clone(), location_history.clone())); + + Ok(()) + } + pub fn create(state_holder: &UiStateHolder, context: &egui::Context, ui_event_channel: &Receiver) -> AppThread { AppThread { ui_state: state_holder.clone(), @@ -57,6 +72,7 @@ impl AppThread { } fn real_main(&mut self) -> anyhow::Result<()> { + debug!("I am ALIVE! HAHAHAHA!"); self.commit(UiState::Loading); let mut config_provider = config::ConfigProvider::new()?; @@ -71,6 +87,8 @@ impl AppThread { config_provider.set_key(&self.decryption_key)?; + self.pick_game(&config_provider); + Ok(()) } diff --git a/src/config.rs b/src/config.rs index 36cd730..8ace72d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,16 +1,21 @@ -use std::{fs::{self, File}, path::PathBuf}; +use std::{fs::{self, File}, path::PathBuf, time::{Instant, SystemTime}}; use anyhow::Context; -use serde::{de, Deserialize, Serialize}; +use egui::ahash::HashMap; +use serde::{Deserialize, Serialize}; +use serde; #[derive(Deserialize, Serialize, Debug)] struct Config { - key: Option> + key: Option>, + + #[serde(default)] + recently_used_game_paths: HashMap } impl Default for Config { fn default() -> Self { - Self { key: Default::default() } + Self { key: Default::default(), recently_used_game_paths: Default::default() } } } @@ -60,4 +65,19 @@ impl ConfigProvider { Ok(()) } + + pub fn get_game_paths(&self) -> anyhow::Result> { + let mut paths: Vec<(&PathBuf, &u64)> = self.config.recently_used_game_paths.iter().collect(); + paths.sort_by(|a, b| a.1.cmp(b.1)); + + Ok(paths.iter().map(|v| v.0.clone()).collect()) + } + + pub fn set_game_path_used(&mut self, which: &PathBuf) -> anyhow::Result<()> { + let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?.as_secs(); + self.config.recently_used_game_paths.insert(which.clone(), current_time); + self.commit()?; + + Ok(()) + } } diff --git a/src/main.rs b/src/main.rs index 168a006..b0a5a5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,12 +9,22 @@ use crossbeam_channel::{Receiver, Sender}; use eframe::egui; use egui::{mutex::{Mutex, RwLock}, Align, Layout, RichText, ThemePreference}; use egui_alignments::{center_horizontal, center_vertical, top_horizontal, Aligner}; -use omori_locator::{get_omori_key, get_omori_path}; +use egui_modal::Modal; use sha2::Digest; fn main() -> anyhow::Result<()> { env_logger::init(); + + #[cfg(target_os = "windows")] + log::trace!("How do you even do serious work on ValorantOS??"); + + #[cfg(target_os = "macos")] + log::trace!("Mac user, rich fuck, send me $500"); + #[cfg(target_os = "linux")] + log::trace!("Linux user... Please touch grass for once"); + + let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_min_inner_size([640.0, 480.0]), @@ -128,6 +138,55 @@ impl eframe::App for Application { } }) }); + }, + UiState::PickGame(steam, others) => { + let invalid_path_modal = Modal::new(ctx, "invalid_path_modal"); + invalid_path_modal.show(|ui| { + invalid_path_modal.title(ui, "Invalid path"); + invalid_path_modal.frame(ui, |ui| { + invalid_path_modal.body(ui, "Please pick a correct installation of OMORI."); + }); + invalid_path_modal.buttons(ui, |ui| { + invalid_path_modal.button(ui, "OK"); + }); + }); + egui::TopBottomPanel::top("key_prompt_title_bar").show(ctx, |ui| { + ui.with_layout(Layout::top_down(Align::Center), |ui| { + ui.label(RichText::new("Select your base game").size(32.0)); + }); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + ui.columns(2, |columns| { + columns[0].vertical(|ui| { + ui.label(RichText::new("Steam Installation").size(24.0)); + match steam { + Ok(path) => { + ui.label(RichText::new(format!("Found game at:\n{}", path.display())).italics()); + ui.separator(); + ui.button(RichText::new("Use Steam version").size(16.0)); + }, + Err(e) => { + ui.label(RichText::new(format!("Failed to find game:\n{}", e)).color(ui.visuals().error_fg_color)); + } + } + }); + + columns[1].vertical(|ui| { + ui.label(RichText::new("Custom").size(24.0)); + if ui.button(RichText::new("Pick game location").size(16.0)).clicked() { + rfd::FileDialog::new().pick_folder(); + invalid_path_modal.open(); + } + ui.separator(); + if others.len() > 0 { + + } else { + ui.label("Once you use a custom location, it will be remembered here."); + } + }); + }); + }); } } } diff --git a/src/omori_locator.rs b/src/omori_locator.rs index b69d56d..8b08a1c 100644 --- a/src/omori_locator.rs +++ b/src/omori_locator.rs @@ -1,7 +1,8 @@ use anyhow::Context; +use log::{debug, info, trace}; use regex::bytes::Regex; use sha2::Digest; -use std::{fs::File, io::Read, path::PathBuf}; +use std::{fs::{self, File}, io::Read, path::PathBuf}; #[cfg(any(target_os = "linux", target_os = "macos"))] fn get_steam_root() -> anyhow::Result { @@ -13,6 +14,8 @@ fn get_steam_root() -> anyhow::Result { #[cfg(target_os="macos")] base.push("Library/Application Support/Steam"); + info!("Using {:?} as steam base", base); + Ok(base) } @@ -29,14 +32,20 @@ fn get_steam_root() -> anyhow::Result { let mut base = PathBuf::from(value); + debug!("Using {:?} as steam base", base); + Ok(base) } +const OMORI_STEAM_APPID: &str = "1150690"; + pub fn get_omori_path() -> anyhow::Result { let mut base = get_steam_root()?; base.push("steamapps"); base.push("libraryfolders.vdf"); + + info!("Searching {:?} for library folders with omori", base); let mut buffer = Vec::new(); @@ -50,7 +59,6 @@ pub fn get_omori_path() -> anyhow::Result { let mut omori_path: Option = None; for key in keys { - println!("{:?}", key); let entry = data.get(key).with_context(|| "Couldn't read specific steam library libraryfolders object.")?; let entry = entry.get(0).with_context(|| "Couldn't read specific steam library libraryfolders object.")?; let entry = entry.get_obj().with_context(|| "libraryfolders parsing failed: Not an object")?; @@ -64,7 +72,7 @@ pub fn get_omori_path() -> anyhow::Result { let path = path.get(0).with_context(|| "libraryfolders parsing failed: No path")?; let path = path.get_str().with_context(|| "libraryfolders parsing failed: No path")?; - if apps.iter().find(|x| (**x).eq("1150690")).is_some() { + if apps.iter().find(|x| (**x).eq(OMORI_STEAM_APPID)).is_some() { let mut base = PathBuf::from(path); #[cfg(not(target_os = "macos"))] @@ -91,7 +99,13 @@ pub fn get_omori_path() -> anyhow::Result { } - omori_path.with_context(|| "Failed to find omori") + let path = omori_path.with_context(|| "Failed to find omori")?; + debug!("{:?} seems to be it.", path); + if validate_omori_installation(&path) { + Ok(path) + } else { + Err(anyhow::anyhow!("The steam installation of OMORI is not valid")) + } } const GAME_KEY_HASH: &[u8; 32] = include_bytes!("keyhash"); @@ -127,4 +141,56 @@ pub fn get_omori_key() -> anyhow::Result> { Err(anyhow::anyhow!("Couldn't find any valid decryption key for OMORI in your Steam installation.")) +} + + +const EXPECTED_PATHS: [&str; 32] = [ + "index.html", + "editor.json", + "audio/bgm/AMB_forest.rpgmvo", + "audio/me/cutscene_omori_saves_player.rpgmvo", + "data/Actors.KEL", + "data/System.KEL", + "data/Atlas.PLUTO", + "data/Troops.KEL", + "fonts/OMORI_GAME.ttf", + "icon/icon.png", + "img/animations/e_calm_down.rpgmvp", + "img/atlases/battleATLAS.rpgmvp", + "img/battlebacks1/battleback_dw_humphrey.rpgmvp", // best area + "img/characters/!FA_OBJECTS_2.rpgmvp", + "img/characters/FA_AUBREY.rpgmvp", + "img/enemies/!battle_snow_bunny.rpgmvp", + "img/faces/01_OMORI_BATTLE.rpgmvp", + "img/overlays/fog.rpgmvp", + "img/parallaxes/!BS_faces.rpgmvp", + "img/pictures/basil_something.rpgmvp", + "img/slotmachine/bet_line_2.rpgmvp", + "img/sv_actors/!battle_aubrey.rpgmvp", + "img/system/bar_gradients.rpgmvp", + "img/system/Window.png", + "img/tilesets/BS_Beach.rpgmvp", + "img/transition/starburst_2.rpgmvp", + "js/rpg_core.js", + "js/plugins/Omori BASE.OMORI", + "languages/en/10_cutscenes_fakeknifefight.HERO", + "maps/FA_SUNSET_INTERIOR2.AUBREY", + "movies/secret_ending.webm", + "movies/Bad End Credits Draft 4.webm" +]; + +pub fn validate_omori_installation(base: &PathBuf) -> bool { + info!("Validating {:?}", base); + for path in EXPECTED_PATHS { + trace!("Checking {path}"); + let mut real = base.clone(); + real.push(path); + + if let Ok(r) = fs::exists(&real) { + if !r { return false; } + } else { return false; } + } + info!("Validation passed"); + + true } \ No newline at end of file