From b6c9c756246111088466cc2e5143d70a0a5117e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=9A=93?= Date: Fri, 9 Feb 2024 14:06:41 -0500 Subject: [PATCH] Implement some of the database editors (#99) * refactor: separate out the code common to all database editors This commit moves the generic skeleton code for all database editors from the item editor into a new file, crates/components/src/database_view.rs. * refactor: move `Scope` and `Occasion` into crates/data/src/shared/mod.rs * feat(skills): implement skill editor * fix: fix ID collision in database view sidepane * feat: outline where all the editors should go in the Data tab * feat(weapons): implement weapon editor * feat(armor): implement armor editor * style(armor): rename "Armors" to "Armor" * feat(states): implement state editor * style(items,armor): increase consistency of naming * style(states): move some widgets around * feat(actors): start implementing the actor editor * style(actors): replace drag values with sliders * fix(actors): fix starting weapon/armor selection using wrong IDs * fix(actors): only allow equippable weapons/armor to be selected * fix(actors): mark project as modified when changing starting equipment * feat: focus search box when `OptionalIdComboBox` opens * feat(actors): implement drawing the parameter graphs * style: add vertical spacing between fields * fix(actors): anti-alias the left and right edges of the graphs * fix(actors): fix off-by-one when calculating graph dimensions * feat(classes): start implementing the class editor * feat(classes): implement editing class skills * feat(classes): implement element and state rank selection * refactor: id_vec selection now always acts like control key is held * fix(classes): improve consistency of invalid rank handling * fix: fix off-by-one error in element selection logic * fix: fix off-by-one error when displaying IDs * refactor(classes): split out the collapsing editor code for the skills * feat(enemies): start implementing the enemy editor * feat(enemies): implement action editing for enemies * style(enemies): reformat action fields a little * fix(enemies): disable '(None)' options for treasure IDs * style(enemies): flip stripe colors * fix(enemies): display switch numbers in action conditions with 4 digits * fix: increase minimum width of the database view * refactor(classes,enemies): move collapsing view into separate functions This commit moves the headers and bodies of the collapsing views for these two editors into new functions to reduce the indentation levels in the code. * style(classes,enemies): add space under collapsing view delete button * feat: truncate selectable label text instead of wrapping This makes it so that the text in the selectable labels in the database editors and the archive manager truncates instead of wrapping. Before, it would wrap, meaning if the text was too long to fit in the layout, it would wrap onto a new line of text. Now, the text will be cut off with "..." if it's too long and will only occupy one line. This makes the heights of the selectable labels uniform so that we can use `.show_rows` on certain scroll areas instead of `.show`. * feat: implement setting maximum number of database entries * fix: use drag value height when calculating database view max height * feat(actors): implement EXP curve viewer * fix(actors): don't allow setting final level lower than initial level * style: add margins on left and right of striped backgrounds * style(actors,enemies): merge EXP curve rows and treasure rows * style: change some margins in `OptionalIdVecComboBox` * fix: change the lower bound for change maximum from 0 to 1 * fix: remove the bounds from the drag value for change maximum We've reached a consensus that the upper bound of 999 is arbitary. It would still be a good idea to limit this to prevent out-of-memory crashes, but I don't know what to use as the upper bound. * fix: keep ID intact when resetting database entries to default * perf: put `IdVecSelection` into a scroll area * fix(classes,enemies): resize rank tables when maximum number of states changes * fix: fix `IdVecSelection` being broken when searching * fix: shift selection now selects normally regardless of search If you use shift selection in an `IdVecSelection` while searching, it it should now select all entries in the range instead of only those in the range that are visible in the search. Someone might want to search for specific IDs as the start and end endpoints of a large selection, so the new behaviour or selecting all entries in the range would be more helpful than the old one. * style: remove horizontal spacing from combo box stripes This commit removes the margins from the stripes in the combo box lists so that the items in the lists are the same width as the search box. `ui.with_stripe()` is now split into `ui.with_padded_stripe()` which has margins and `ui.with_stripe()` which doesn't. * style: decrease space under search box in `IdVecSelection` slightly * perf: cache search results instead of recomputing every frame This optimizes the search functionality in the combo boxes and in the id_vec selection to cache the search results whenever the search query or the items are changed and then use the cached results. In order to track when items are changed, I've added a new field into the `ModifiedState` that tracks if the data cache was modified during the current frame. * feat: add warning if user tries to set maximum above 999 * perf: remove remaining O(n) database operations that run every frame This commit optimizes the database editors further by removing a linear-time check for if an id_vec is sorted that was running every frame. Now, this check only runs when a database entry is modified. The state editor was also cloning the names of every state every frame to get around the borrow checker. The cloning of every state's name has been replaced with an O(1) operation. * feat: add search box to database view left pane * fix: clear database view search box when view is closed * fix: fix crash when decreasing maximum number of entries * fix: fix database view left pane sometimes inverting stripe colors * fix: justify the width of the above 999 warning --- Cargo.lock | 1 + crates/components/Cargo.toml | 1 + crates/components/src/collapsing_view.rs | 162 +++++ crates/components/src/database_view.rs | 274 +++++++++ crates/components/src/filesystem_view.rs | 10 +- crates/components/src/id_vec.rs | 546 +++++++++++------ crates/components/src/lib.rs | 285 ++++++--- crates/components/src/ui_ext.rs | 39 ++ crates/core/src/lib.rs | 29 + crates/data/src/lib.rs | 54 +- crates/data/src/rgss_structs.rs | 5 + crates/data/src/rmxp/enemy.rs | 63 +- crates/data/src/rmxp/item.rs | 69 +-- crates/data/src/rmxp/skill.rs | 25 +- crates/data/src/shared/mod.rs | 51 ++ crates/ui/src/lib.rs | 1 + crates/ui/src/windows/actors.rs | 733 +++++++++++++++++++++++ crates/ui/src/windows/armor.rs | 264 ++++++++ crates/ui/src/windows/classes.rs | 310 ++++++++++ crates/ui/src/windows/enemies.rs | 662 ++++++++++++++++++++ crates/ui/src/windows/items.rs | 607 ++++++++----------- crates/ui/src/windows/mod.rs | 14 + crates/ui/src/windows/skills.rs | 367 ++++++++++++ crates/ui/src/windows/states.rs | 390 ++++++++++++ crates/ui/src/windows/weapons.rs | 272 +++++++++ src/app/mod.rs | 6 + src/app/top_bar.rs | 80 ++- 27 files changed, 4614 insertions(+), 706 deletions(-) create mode 100644 crates/components/src/collapsing_view.rs create mode 100644 crates/components/src/database_view.rs create mode 100644 crates/ui/src/windows/actors.rs create mode 100644 crates/ui/src/windows/armor.rs create mode 100644 crates/ui/src/windows/classes.rs create mode 100644 crates/ui/src/windows/enemies.rs create mode 100644 crates/ui/src/windows/skills.rs create mode 100644 crates/ui/src/windows/states.rs create mode 100644 crates/ui/src/windows/weapons.rs diff --git a/Cargo.lock b/Cargo.lock index 99ea426f..3dfd682b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3106,6 +3106,7 @@ dependencies = [ "luminol-egui-wgpu", "luminol-filesystem", "luminol-graphics", + "parking_lot", "qp-trie", "strum", "syntect", diff --git a/crates/components/Cargo.toml b/crates/components/Cargo.toml index 055414fe..aa03725e 100644 --- a/crates/components/Cargo.toml +++ b/crates/components/Cargo.toml @@ -45,5 +45,6 @@ indextree = "4.6.0" lexical-sort = "0.3.1" fragile.workspace = true +parking_lot.workspace = true fuzzy-matcher = "0.3.7" diff --git a/crates/components/src/collapsing_view.rs b/crates/components/src/collapsing_view.rs new file mode 100644 index 00000000..49222687 --- /dev/null +++ b/crates/components/src/collapsing_view.rs @@ -0,0 +1,162 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use crate::UiExt; + +/// A component that shows many copies of a layout and only allows one of them to be expanded at a +/// time. +#[derive(Default)] +pub struct CollapsingView { + depersisted_entries: usize, + expanded_entry: luminol_data::OptionVec>, + disable_animations: bool, +} + +impl CollapsingView { + pub fn new() -> Self { + Default::default() + } + + /// Cancels all pending animations for expanding and collapsing entries and expands/collapses + /// them immediately this frame. + pub fn clear_animations(&mut self) { + self.disable_animations = true; + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + id: usize, + vec: &mut Vec, + mut show_header: impl FnMut(&mut egui::Ui, usize, &T), + mut show_body: impl FnMut(&mut egui::Ui, usize, &mut T) -> egui::Response, + ) -> egui::Response + where + T: Default, + { + let mut inner_response = ui.with_cross_justify(|ui| { + let mut modified = false; + let mut deleted_entry = None; + let mut new_entry = false; + + ui.group(|ui| { + if self.expanded_entry.get(id).is_none() { + self.expanded_entry.insert(id, None); + } + let expanded_entry = self.expanded_entry.get_mut(id).unwrap(); + + for (i, entry) in vec.iter_mut().enumerate() { + let ui_id = ui.make_persistent_id(i); + + // Forget whether the collapsing header was open from the last time + // the editor was open + let depersisted = i < self.depersisted_entries; + if !depersisted { + self.depersisted_entries += 1; + if let Some(h) = + egui::collapsing_header::CollapsingState::load(ui.ctx(), ui_id) + { + h.remove(ui.ctx()); + } + ui.ctx().animate_bool_with_time(ui_id, false, 0.); + } + + let mut header = + egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + ui_id, + false, + ); + let expanded = + (self.disable_animations || depersisted) && *expanded_entry == Some(i); + header.set_open(expanded); + if self.disable_animations { + ui.ctx().animate_bool_with_time(ui_id, expanded, 0.); + } + + let layout = *ui.layout(); + let (expand_button_response, _, _) = header + .show_header(ui, |ui| { + ui.with_layout(layout, |ui| { + show_header(ui, i, entry); + }); + }) + .body(|ui| { + ui.with_layout(layout, |ui| { + modified |= show_body(ui, i, entry).changed(); + + if ui.button("Delete").clicked() { + modified = true; + deleted_entry = Some(i); + } + + ui.add_space(ui.spacing().item_spacing.y); + }); + }); + + if expand_button_response.clicked() { + *expanded_entry = (*expanded_entry != Some(i)).then_some(i); + } + } + + ui.add_space(2. * ui.spacing().item_spacing.y); + + if ui.button("New").clicked() { + modified = true; + *expanded_entry = Some(vec.len()); + vec.push(Default::default()); + new_entry = true; + } + }); + + self.disable_animations = false; + + if let Some(i) = deleted_entry { + if let Some(expanded_entry) = self.expanded_entry.get_mut(id) { + if *expanded_entry == Some(i) { + self.disable_animations = true; + *expanded_entry = None; + } else if expanded_entry.is_some() && *expanded_entry > Some(i) { + self.disable_animations = true; + *expanded_entry = Some(expanded_entry.unwrap() - 1); + } + } + + vec.remove(i); + } + + self.depersisted_entries = vec.len(); + if new_entry { + self.depersisted_entries -= 1; + } + + modified + }); + + if inner_response.inner { + inner_response.response.mark_changed(); + } + inner_response.response + } +} diff --git a/crates/components/src/database_view.rs b/crates/components/src/database_view.rs new file mode 100644 index 00000000..6ebad57c --- /dev/null +++ b/crates/components/src/database_view.rs @@ -0,0 +1,274 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use crate::UiExt; +use itertools::Itertools; + +pub struct DatabaseViewResponse { + /// The returned value of the `inner` closure passed to `show` if the editor pane was rendered, + /// otherwise `None`. + pub inner: Option, + /// Was any individual entry or the number of entries modified by us? + pub modified: bool, +} + +#[derive(Default)] +pub struct DatabaseView { + show_called_at_least_once: bool, + selected_id: usize, + maximum: Option, +} + +impl DatabaseView { + pub fn new() -> Self { + Default::default() + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + update_state: &luminol_core::UpdateState<'_>, + label: impl Into, + vec: &mut Vec, + formatter: impl Fn(&T) -> String, + inner: impl FnOnce(&mut egui::Ui, &mut Vec, usize) -> R, + ) -> egui::InnerResponse> + where + T: luminol_data::rpg::DatabaseEntry, + { + let mut modified = false; + + let p = update_state + .project_config + .as_ref() + .expect("project not loaded") + .project + .persistence_id; + + if self.maximum.is_none() { + self.maximum = Some(vec.len()); + } + + let button_height = ui.spacing().interact_size.y.max( + ui.text_style_height(&egui::TextStyle::Button) + 2. * ui.spacing().button_padding.y, + ); + + egui::SidePanel::left(ui.make_persistent_id("sidepanel")).show_inside(ui, |ui| { + ui.with_right_margin(ui.spacing().window_margin.right, |ui| { + ui.with_cross_justify(|ui| { + ui.with_layout( + egui::Layout::bottom_up(ui.layout().horizontal_align()), + |ui| { + ui.horizontal(|ui| { + ui.style_mut().wrap = Some(true); + + ui.add(egui::DragValue::new(self.maximum.as_mut().unwrap())); + + if ui + .add_enabled( + self.maximum != Some(vec.len()), + egui::Button::new(ui.truncate_text("Set Maximum")), + ) + .clicked() + { + modified = true; + let mut index = vec.len(); + vec.resize_with(self.maximum.unwrap(), || { + let item = T::default_with_id(index); + index += 1; + item + }); + }; + }); + + if vec.len() <= 999 && self.maximum.is_some_and(|m| m > 999) { + egui::Frame::none().show(ui, |ui| { + ui.style_mut() + .visuals + .widgets + .noninteractive + .bg_stroke + .color = ui.style().visuals.warn_fg_color; + egui::Frame::group(ui.style()) + .fill(ui.visuals().gray_out(ui.visuals().gray_out( + ui.visuals().gray_out(ui.style().visuals.warn_fg_color), + ))) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label(egui::RichText::new("Setting the maximum above 999 may introduce performance issues and instability").color(ui.style().visuals.warn_fg_color)); + }); + }); + } + + ui.add_space(ui.spacing().item_spacing.y); + + ui.with_layout( + egui::Layout::top_down(ui.layout().horizontal_align()), + |ui| { + ui.with_cross_justify(|ui| { + ui.label(label); + + let state_id = ui.make_persistent_id("DatabaseView"); + + // Get cached search string and search matches from egui memory + let (mut search_string, search_matched_ids_lock) = self + .show_called_at_least_once + .then(|| ui.data(|d| d.get_temp(state_id))) + .flatten() + .unwrap_or_else(|| { + ( + String::new(), + // We use a mutex here because if we just put the Vec directly into + // memory, egui will clone it every time we get it from memory + std::sync::Arc::new(parking_lot::Mutex::new( + (0..vec.len()).collect_vec(), + )), + ) + }); + let mut search_matched_ids = search_matched_ids_lock.lock(); + + self.selected_id = + self.selected_id.min(vec.len().saturating_sub(1)); + + let search_box_response = ui.add( + egui::TextEdit::singleline(&mut search_string) + .hint_text("Search"), + ); + + ui.add_space(ui.spacing().item_spacing.y); + + // If the user edited the contents of the search box or if the data cache changed + // this frame, recalculate the search results + let search_needs_update = modified + || *update_state.modified_during_prev_frame + || search_box_response.changed(); + if search_needs_update { + let matcher = + fuzzy_matcher::skim::SkimMatcherV2::default(); + search_matched_ids.clear(); + search_matched_ids.extend( + vec.iter().enumerate().filter_map(|(id, entry)| { + matcher + .fuzzy( + &formatter(entry), + &search_string, + false, + ) + .is_some() + .then_some(id) + }), + ); + } + + egui::ScrollArea::vertical().id_source(p).show_rows( + ui, + button_height, + search_matched_ids.len(), + |ui, range| { + ui.set_width(ui.available_width()); + + let mut is_faint = range.start % 2 != 0; + + for id in search_matched_ids[range].iter().copied() + { + let entry = &mut vec[id]; + + ui.with_stripe(is_faint, |ui| { + let response = ui + .selectable_value( + &mut self.selected_id, + id, + ui.truncate_text(formatter(entry)), + ) + .interact(egui::Sense::click()); + + if response.clicked() { + response.request_focus(); + } + + // Reset this entry if delete or backspace + // is pressed while this entry is focused + if response.has_focus() + && ui.input(|i| { + i.key_pressed(egui::Key::Delete) + || i.key_pressed( + egui::Key::Backspace, + ) + }) + { + *entry = T::default_with_id(id); + modified = true; + } + }); + + is_faint = !is_faint; + } + }, + ); + + // Save the search string and the search results back into egui memory + drop(search_matched_ids); + ui.data_mut(|d| { + d.insert_temp( + state_id, + (search_string, search_matched_ids_lock), + ) + }); + + self.show_called_at_least_once = true; + }); + }, + ); + }, + ); + }); + }); + }); + + ui.with_left_margin(ui.spacing().window_margin.left, |ui| { + ui.with_cross_justify(|ui| { + egui::ScrollArea::vertical() + .id_source(p) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_width( + 2. * (ui.spacing().slider_width + ui.spacing().interact_size.x) + + ui.spacing().indent + + 12. // `egui::Frame::group` inner margins are hardcoded to 6 + // points on each side + + 5. * ui.spacing().item_spacing.x, + ); + + DatabaseViewResponse { + inner: (self.selected_id < vec.len()) + .then(|| inner(ui, vec, self.selected_id)), + modified, + } + }) + .inner + }) + }) + .inner + } +} diff --git a/crates/components/src/filesystem_view.rs b/crates/components/src/filesystem_view.rs index 9abc4a3b..185efa69 100644 --- a/crates/components/src/filesystem_view.rs +++ b/crates/components/src/filesystem_view.rs @@ -275,7 +275,7 @@ where Entry::File { name, selected } => { ui.with_stripe(is_faint, |ui| { if ui - .add(egui::SelectableLabel::new(*selected, name.to_string())) + .selectable_label(*selected, ui.truncate_text(name.to_string())) .clicked() { should_toggle = true; @@ -315,9 +315,9 @@ where ui.with_layout(layout, |ui| { ui.with_stripe(is_faint, |ui| { if ui - .add(egui::SelectableLabel::new( + .selectable_label( *selected, - format!( + ui.truncate_text(format!( "{} {}", if *selected { '▣' @@ -328,8 +328,8 @@ where '⊟' }, name - ), - )) + )), + ) .clicked() { should_toggle = true; diff --git a/crates/components/src/id_vec.rs b/crates/components/src/id_vec.rs index 1a336c3b..b43b7e80 100644 --- a/crates/components/src/id_vec.rs +++ b/crates/components/src/id_vec.rs @@ -23,29 +23,40 @@ // Program grant you additional permission to convey the resulting work. use crate::UiExt; +use itertools::Itertools; #[derive(Default, Clone)] struct IdVecSelectionState { pivot: Option, - hide_tooltip: bool, search_string: String, + search_matched_ids_lock: std::sync::Arc>>, } pub struct IdVecSelection<'a, H, F> { id_source: H, reference: &'a mut Vec, - len: usize, + id_range: std::ops::Range, formatter: F, clear_search: bool, + search_needs_update: bool, } pub struct IdVecPlusMinusSelection<'a, H, F> { id_source: H, plus: &'a mut Vec, minus: &'a mut Vec, - len: usize, + id_range: std::ops::Range, formatter: F, clear_search: bool, + search_needs_update: bool, +} + +pub struct RankSelection<'a, H, F> { + id_source: H, + reference: &'a mut luminol_data::Table1, + formatter: F, + clear_search: bool, + search_needs_update: bool, } impl<'a, H, F> IdVecSelection<'a, H, F> @@ -54,13 +65,20 @@ where F: Fn(usize) -> String, { /// Creates a new widget for changing the contents of an `id_vec`. - pub fn new(id_source: H, reference: &'a mut Vec, len: usize, formatter: F) -> Self { + pub fn new( + update_state: &luminol_core::UpdateState<'_>, + id_source: H, + reference: &'a mut Vec, + id_range: std::ops::Range, + formatter: F, + ) -> Self { Self { id_source, reference, - len, + id_range, formatter, clear_search: false, + search_needs_update: *update_state.modified_during_prev_frame, } } @@ -77,19 +95,48 @@ where { /// Creates a new widget for changing the contents of a pair of `id_vec`s. pub fn new( + update_state: &luminol_core::UpdateState<'_>, id_source: H, plus: &'a mut Vec, minus: &'a mut Vec, - len: usize, + id_range: std::ops::Range, formatter: F, ) -> Self { Self { id_source, plus, minus, - len, + id_range, + formatter, + clear_search: false, + search_needs_update: *update_state.modified_during_prev_frame, + } + } + + /// Clears the search box. + pub fn clear_search(&mut self) { + self.clear_search = true; + } +} + +impl<'a, H, F> RankSelection<'a, H, F> +where + H: std::hash::Hash, + F: Fn(usize) -> String, +{ + /// Creates a new widget for changing the contents of a rank table. + pub fn new( + update_state: &luminol_core::UpdateState<'_>, + id_source: H, + reference: &'a mut luminol_data::Table1, + formatter: F, + ) -> Self { + Self { + id_source, + reference, formatter, clear_search: false, + search_needs_update: *update_state.modified_during_prev_frame, } } @@ -105,77 +152,100 @@ where F: Fn(usize) -> String, { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - if !self.reference.is_sorted() { + if self.search_needs_update && !self.reference.is_sorted() { self.reference.sort_unstable(); } + let first_id = self.id_range.start; + let state_id = ui.make_persistent_id(egui::Id::new(self.id_source).with("IdVecSelection")); let mut state = ui .data(|d| d.get_temp::(state_id)) - .unwrap_or_default(); + .unwrap_or_else(|| { + IdVecSelectionState { + // We use a mutex here because if we just put the Vec directly into + // memory, egui will clone it every time we get it from memory + search_matched_ids_lock: std::sync::Arc::new(parking_lot::Mutex::new( + self.id_range.clone().collect_vec(), + )), + ..Default::default() + } + }); if self.clear_search { state.search_string = String::new(); } + if self.search_needs_update { + state.search_matched_ids_lock = + std::sync::Arc::new(parking_lot::Mutex::new(self.id_range.clone().collect_vec())); + } + let mut search_matched_ids = state.search_matched_ids_lock.lock(); - let mut index = 0; let mut clicked_id = None; - let matcher = fuzzy_matcher::skim::SkimMatcherV2::default(); - let mut response = ui .group(|ui| { ui.with_cross_justify(|ui| { ui.set_width(ui.available_width()); - ui.add( + let search_box_response = ui.add( egui::TextEdit::singleline(&mut state.search_string).hint_text("Search"), ); - let mut is_faint = false; - - for id in 0..self.len { - let is_id_selected = self.reference.get(index).is_some_and(|x| *x == id); - if is_id_selected { - index += 1; - } - - let formatted = (self.formatter)(id); - if matcher - .fuzzy(&formatted, &state.search_string, false) - .is_none() - { - continue; - } + ui.add_space(ui.spacing().item_spacing.y); + + // If the user edited the contents of the search box or if the data cache changed + // this frame, recalculate the search results + let search_needs_update = + self.search_needs_update || search_box_response.changed(); + if search_needs_update { + let matcher = fuzzy_matcher::skim::SkimMatcherV2::default(); + search_matched_ids.clear(); + search_matched_ids.extend(self.id_range.filter(|id| { + matcher + .fuzzy(&(self.formatter)(*id), &state.search_string, false) + .is_some() + })); + } - ui.with_stripe(is_faint, |ui| { - if ui - .selectable_label(is_id_selected, (self.formatter)(id)) - .clicked() - { - clicked_id = Some(id); + let button_height = ui.spacing().interact_size.y.max( + ui.text_style_height(&egui::TextStyle::Button) + + 2. * ui.spacing().button_padding.y, + ); + egui::ScrollArea::vertical() + .id_source(state_id.with("scroll_area")) + .min_scrolled_height(200.) + .show_rows(ui, button_height, search_matched_ids.len(), |ui, range| { + let mut is_faint = range.start % 2 != 0; + + for id in search_matched_ids[range].iter().copied() { + let is_id_selected = + self.reference.binary_search(&(id - first_id)).is_ok(); + + ui.with_stripe(is_faint, |ui| { + if ui + .selectable_label( + is_id_selected, + ui.truncate_text((self.formatter)(id)), + ) + .clicked() + { + clicked_id = Some(id - first_id); + } + }); + is_faint = !is_faint; } }); - is_faint = !is_faint; - } }) .inner }) .response; if let Some(clicked_id) = clicked_id { - state.hide_tooltip = true; - let modifiers = ui.input(|i| i.modifiers); - let is_pivot_selected = !modifiers.command - || state - .pivot - .is_some_and(|pivot| self.reference.contains(&pivot)); - - // Unless control is held, deselect everything before doing anything - if !modifiers.command { - self.reference.clear(); - } + let is_pivot_selected = state + .pivot + .is_some_and(|pivot| self.reference.contains(&pivot)); let old_len = self.reference.len(); @@ -201,20 +271,12 @@ where index < old_len && self.reference.get(index).is_some_and(|x| *x == id); if is_id_selected { index += 1; - } else if matcher - .fuzzy(&(self.formatter)(id), &state.search_string, false) - .is_some() - { + } else { self.reference.push(id); } } } else { - self.reference.retain(|id| { - !range.contains(id) - || matcher - .fuzzy(&(self.formatter)(*id), &state.search_string, false) - .is_none() - }); + self.reference.retain(|id| !range.contains(id)); } } else { state.pivot = Some(clicked_id); @@ -228,15 +290,7 @@ where response.mark_changed(); } - if !state.hide_tooltip { - response = response.on_hover_ui_at_pointer(|ui| { - ui.label("Click to select single entries"); - ui.label("Ctrl+click to select multiple entries or deselect entries"); - ui.label("Shift+click to select a range"); - ui.label("To select multiple ranges or deselect a range, Ctrl+click the first endpoint and Ctrl+Shift+click the second endpoint"); - }); - } - + drop(search_matched_ids); ui.data_mut(|d| d.insert_temp(state_id, state)); response @@ -249,113 +303,120 @@ where F: Fn(usize) -> String, { fn ui(self, ui: &mut egui::Ui) -> egui::Response { - if !self.plus.is_sorted() { - self.plus.sort_unstable(); - } - if !self.minus.is_sorted() { - self.minus.sort_unstable(); + if self.search_needs_update { + if !self.plus.is_sorted() { + self.plus.sort_unstable(); + } + if !self.minus.is_sorted() { + self.minus.sort_unstable(); + } } + let first_id = self.id_range.start; + let state_id = ui.make_persistent_id(egui::Id::new(self.id_source).with("IdVecPlusMinusSelection")); let mut state = ui .data(|d| d.get_temp::(state_id)) - .unwrap_or_default(); + .unwrap_or_else(|| { + IdVecSelectionState { + // We use a mutex here because if we just put the Vec directly into + // memory, egui will clone it every time we get it from memory + search_matched_ids_lock: std::sync::Arc::new(parking_lot::Mutex::new( + self.id_range.clone().collect_vec(), + )), + ..Default::default() + } + }); if self.clear_search { state.search_string = String::new(); } + if self.search_needs_update { + state.search_matched_ids_lock = + std::sync::Arc::new(parking_lot::Mutex::new(self.id_range.clone().collect_vec())); + } + let mut search_matched_ids = state.search_matched_ids_lock.lock(); - let mut plus_index = 0; - let mut minus_index = 0; let mut clicked_id = None; - let matcher = fuzzy_matcher::skim::SkimMatcherV2::default(); - let mut response = ui .group(|ui| { ui.with_cross_justify(|ui| { ui.set_width(ui.available_width()); - ui.add( + let search_box_response = ui.add( egui::TextEdit::singleline(&mut state.search_string).hint_text("Search"), ); - let mut is_faint = false; - - for id in 0..self.len { - let is_id_plus = self.plus.get(plus_index).is_some_and(|x| *x == id); - if is_id_plus { - plus_index += 1; - } - let is_id_minus = self.minus.get(minus_index).is_some_and(|x| *x == id); - if is_id_minus { - minus_index += 1; - } - - let formatted = (self.formatter)(id); - if matcher - .fuzzy(&formatted, &state.search_string, false) - .is_none() - { - continue; - } - - ui.with_stripe(is_faint, |ui| { - // Make the background of the selectable label red if it's - // a minus - if is_id_minus { - ui.visuals_mut().selection.bg_fill = - ui.visuals().gray_out(ui.visuals().error_fg_color); - } + ui.add_space(ui.spacing().item_spacing.y); + + // If the user edited the contents of the search box or if the data cache changed + // this frame, recalculate the search results + let search_needs_update = + self.search_needs_update || search_box_response.changed(); + if search_needs_update { + let matcher = fuzzy_matcher::skim::SkimMatcherV2::default(); + search_matched_ids.clear(); + search_matched_ids.extend(self.id_range.filter(|id| { + matcher + .fuzzy(&(self.formatter)(*id), &state.search_string, false) + .is_some() + })); + } - let label = (self.formatter)(id); - if ui - .selectable_label( - is_id_plus || is_id_minus, - if is_id_plus { - format!("+ {label}") - } else if is_id_minus { - format!("‒ {label}") - } else { - label - }, - ) - .clicked() - { - clicked_id = Some(id); + let button_height = ui.spacing().interact_size.y.max( + ui.text_style_height(&egui::TextStyle::Button) + + 2. * ui.spacing().button_padding.y, + ); + egui::ScrollArea::vertical() + .id_source(state_id.with("scroll_area")) + .min_scrolled_height(200.) + .show_rows(ui, button_height, search_matched_ids.len(), |ui, range| { + let mut is_faint = range.start % 2 != 0; + + for id in search_matched_ids[range].iter().copied() { + let is_id_plus = self.plus.binary_search(&(id - first_id)).is_ok(); + let is_id_minus = + self.minus.binary_search(&(id - first_id)).is_ok(); + + ui.with_stripe(is_faint, |ui| { + // Make the background of the selectable label red if it's + // a minus + if is_id_minus { + ui.visuals_mut().selection.bg_fill = + ui.visuals().gray_out(ui.visuals().error_fg_color); + } + + let label = (self.formatter)(id); + if ui + .selectable_label( + is_id_plus || is_id_minus, + ui.truncate_text(if is_id_plus { + format!("+ {label}") + } else if is_id_minus { + format!("‒ {label}") + } else { + label + }), + ) + .clicked() + { + clicked_id = Some(id - first_id); + } + }); + is_faint = !is_faint; } }); - is_faint = !is_faint; - } }) .inner }) .response; if let Some(clicked_id) = clicked_id { - state.hide_tooltip = true; - let modifiers = ui.input(|i| i.modifiers); - // Unless control is held, deselect everything before doing anything - if !modifiers.command { - let plus_contains_clicked_id = self.plus.contains(&clicked_id); - let minus_contains_pivot = state.pivot.and_then(|pivot| { - (modifiers.shift && self.minus.contains(&pivot)).then_some(pivot) - }); - self.plus.clear(); - self.minus.clear(); - if plus_contains_clicked_id { - self.plus.push(clicked_id); - } - if let Some(pivot) = minus_contains_pivot { - self.minus.push(pivot); - } - } - let is_pivot_minus = state.pivot.is_some_and(|pivot| self.minus.contains(&pivot)); - let is_pivot_plus = (!modifiers.command && !is_pivot_minus) - || state.pivot.is_some_and(|pivot| self.plus.contains(&pivot)); + let is_pivot_plus = state.pivot.is_some_and(|pivot| self.plus.contains(&pivot)); let old_plus_len = self.plus.len(); let old_minus_len = self.minus.len(); @@ -372,12 +433,7 @@ where }; if is_pivot_plus { - self.minus.retain(|id| { - !range.contains(id) - || matcher - .fuzzy(&(self.formatter)(*id), &state.search_string, false) - .is_none() - }); + self.minus.retain(|id| !range.contains(id)); let mut plus_index = self .plus .iter() @@ -388,20 +444,12 @@ where && self.plus.get(plus_index).is_some_and(|x| *x == id); if is_id_plus { plus_index += 1; - } else if matcher - .fuzzy(&(self.formatter)(id), &state.search_string, false) - .is_some() - { + } else { self.plus.push(id); } } } else if is_pivot_minus { - self.plus.retain(|id| { - !range.contains(id) - || matcher - .fuzzy(&(self.formatter)(*id), &state.search_string, false) - .is_none() - }); + self.plus.retain(|id| !range.contains(id)); let mut minus_index = self .minus .iter() @@ -412,26 +460,13 @@ where && self.minus.get(minus_index).is_some_and(|x| *x == id); if is_id_minus { minus_index += 1; - } else if matcher - .fuzzy(&(self.formatter)(id), &state.search_string, false) - .is_some() - { + } else { self.minus.push(id); } } } else { - self.plus.retain(|id| { - !range.contains(id) - || matcher - .fuzzy(&(self.formatter)(*id), &state.search_string, false) - .is_none() - }); - self.minus.retain(|id| { - !range.contains(id) - || matcher - .fuzzy(&(self.formatter)(*id), &state.search_string, false) - .is_none() - }); + self.plus.retain(|id| !range.contains(id)); + self.minus.retain(|id| !range.contains(id)); } } else { state.pivot = Some(clicked_id); @@ -450,15 +485,168 @@ where response.mark_changed(); } - if !state.hide_tooltip { - response = response.on_hover_ui_at_pointer(|ui| { - ui.label("Click to select single entries"); - ui.label("Ctrl+click to select multiple entries or deselect entries"); - ui.label("Shift+click to select a range"); - ui.label("To select multiple ranges or deselect a range, Ctrl+click the first endpoint and Ctrl+Shift+click the second endpoint"); + drop(search_matched_ids); + ui.data_mut(|d| d.insert_temp(state_id, state)); + + response + } +} + +impl<'a, H, F> egui::Widget for RankSelection<'a, H, F> +where + H: std::hash::Hash, + F: Fn(usize) -> String, +{ + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let state_id = ui.make_persistent_id(egui::Id::new(self.id_source).with("RankSelection")); + let mut state = ui + .data(|d| d.get_temp::(state_id)) + .unwrap_or_else(|| { + IdVecSelectionState { + // We use a mutex here because if we just put the Vec directly into + // memory, egui will clone it every time we get it from memory + search_matched_ids_lock: std::sync::Arc::new(parking_lot::Mutex::new( + (0..self.reference.xsize() - 1).collect_vec(), + )), + ..Default::default() + } }); + if self.clear_search { + state.search_string = String::new(); + } + if self.search_needs_update { + state.search_matched_ids_lock = std::sync::Arc::new(parking_lot::Mutex::new( + (0..self.reference.xsize() - 1).collect_vec(), + )); + } + let mut search_matched_ids = state.search_matched_ids_lock.lock(); + + let mut clicked_id = None; + + let mut response = ui + .group(|ui| { + ui.with_cross_justify(|ui| { + ui.set_width(ui.available_width()); + + let search_box_response = ui.add( + egui::TextEdit::singleline(&mut state.search_string).hint_text("Search"), + ); + + ui.add_space(ui.spacing().item_spacing.y); + + // If the user edited the contents of the search box or if the data cache changed + // this frame, recalculate the search results + let search_needs_update = + self.search_needs_update || search_box_response.changed(); + if search_needs_update { + let matcher = fuzzy_matcher::skim::SkimMatcherV2::default(); + search_matched_ids.clear(); + search_matched_ids.extend((0..self.reference.xsize() - 1).filter(|id| { + matcher + .fuzzy(&(self.formatter)(*id), &state.search_string, false) + .is_some() + })); + } + + let button_height = ui.spacing().interact_size.y.max( + ui.text_style_height(&egui::TextStyle::Button) + + 2. * ui.spacing().button_padding.y, + ); + egui::ScrollArea::vertical() + .id_source(state_id.with("scroll_area")) + .min_scrolled_height(200.) + .show_rows(ui, button_height, search_matched_ids.len(), |ui, range| { + let mut is_faint = range.start % 2 != 0; + + for (id, rank) in search_matched_ids[range] + .iter() + .copied() + .map(|id| (id, self.reference[id + 1])) + { + ui.with_stripe(is_faint, |ui| { + // Color the background of the selectable label depending on the + // rank + ui.visuals_mut().selection.bg_fill = match rank { + 2 => ui.visuals().gray_out(ui.visuals().selection.bg_fill), + 4 => ui.visuals().gray_out(ui.visuals().gray_out( + ui.visuals().gray_out(ui.visuals().error_fg_color), + )), + 5 => ui.visuals().gray_out( + ui.visuals().gray_out(ui.visuals().error_fg_color), + ), + 6 => ui.visuals().gray_out(ui.visuals().error_fg_color), + _ => ui.visuals().selection.bg_fill, + }; + + let label = (self.formatter)(id); + if ui + .selectable_label( + matches!(rank, 1 | 2 | 4 | 5 | 6), + ui.truncate_text(format!( + "{} - {label}", + match rank { + 1 => 'A', + 2 => 'B', + 3 => 'C', + 4 => 'D', + 5 => 'E', + 6 => 'F', + _ => '?', + } + )), + ) + .clicked() + { + clicked_id = Some(id); + } + }); + is_faint = !is_faint; + } + }); + }) + .inner + }) + .response; + + if let Some(clicked_id) = clicked_id { + let modifiers = ui.input(|i| i.modifiers); + + let pivot_rank = state + .pivot + .and_then(|pivot| self.reference.as_slice()[1..].get(pivot).copied()); + + // Select all the entries between this one and the pivot if shift is + // held and the pivot is selected, or deselect them if the pivot is + // deselected + if modifiers.shift && state.pivot.is_some() { + let pivot = state.pivot.unwrap(); + let range = if pivot < clicked_id { + pivot..=clicked_id + } else { + clicked_id..=pivot + }; + + for id in range { + self.reference[id + 1] = pivot_rank.unwrap_or(3); + } + } else { + if Some(clicked_id) == state.pivot { + self.reference[clicked_id + 1] = pivot_rank.unwrap_or(3); + } + state.pivot = Some(clicked_id); + let id = clicked_id + 1; + self.reference[id] = self.reference[id].saturating_sub(1); + if self.reference[id] == 0 { + self.reference[id] = 6; + } else if self.reference[id] < 0 || self.reference[id] >= 6 { + self.reference[id] = 3; + } + } + + response.mark_changed(); } + drop(search_matched_ids); ui.data_mut(|d| d.insert_temp(state_id, state)); response diff --git a/crates/components/src/lib.rs b/crates/components/src/lib.rs index 64fb53e6..7104e95c 100644 --- a/crates/components/src/lib.rs +++ b/crates/components/src/lib.rs @@ -24,6 +24,8 @@ #![cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))] #![feature(is_sorted)] +use itertools::Itertools; + /// Syntax highlighter pub mod syntax_highlighting; @@ -42,8 +44,14 @@ pub use command_view::CommandView; mod filesystem_view; pub use filesystem_view::FileSystemView; +mod database_view; +pub use database_view::DatabaseView; + +mod collapsing_view; +pub use collapsing_view::CollapsingView; + mod id_vec; -pub use id_vec::{IdVecPlusMinusSelection, IdVecSelection}; +pub use id_vec::{IdVecPlusMinusSelection, IdVecSelection, RankSelection}; mod ui_ext; pub use ui_ext::UiExt; @@ -104,10 +112,13 @@ where let mut changed = false; let mut response = ui .vertical(|ui| { + let spacing = ui.spacing().item_spacing.y; + ui.add_space(spacing); ui.add(egui::Label::new(format!("{}:", self.name)).truncate(true)); if ui.add(self.widget).changed() { changed = true; }; + ui.add_space(spacing); }) .response; if changed { @@ -154,7 +165,7 @@ where .selectable_label( std::mem::discriminant(self.reference) == std::mem::discriminant(&variant), - variant.to_string(), + ui.truncate_text(variant.to_string()), ) .clicked() { @@ -172,103 +183,130 @@ where } } -pub struct OptionalIdComboBox<'a, H, F> { +pub struct OptionalIdComboBox<'a, R, I, H, F> { id_source: H, - reference: &'a mut Option, - len: usize, + reference: &'a mut R, + id_iter: I, formatter: F, - retain_search: bool, + search_needs_update: bool, + allow_none: bool, } -impl<'a, H, F> OptionalIdComboBox<'a, H, F> +impl<'a, R, I, H, F> OptionalIdComboBox<'a, R, I, H, F> where + I: Iterator + Clone, H: std::hash::Hash, F: Fn(usize) -> String, { /// Creates a combo box that can be used to change the ID of an `optional_id` field in the data /// cache. - pub fn new(id_source: H, reference: &'a mut Option, len: usize, formatter: F) -> Self { + pub fn new( + update_state: &luminol_core::UpdateState<'_>, + id_source: H, + reference: &'a mut R, + id_iter: I, + formatter: F, + ) -> Self { Self { id_source, reference, - len, + id_iter, formatter, - retain_search: true, + search_needs_update: *update_state.modified_during_prev_frame, + allow_none: true, } } - /// Clears the search box for this combo box. - pub fn clear_search(&mut self) { - self.retain_search = false; - } -} - -impl<'a, H, F> egui::Widget for OptionalIdComboBox<'a, H, F> -where - H: std::hash::Hash, - F: Fn(usize) -> String, -{ - fn ui(self, ui: &mut egui::Ui) -> egui::Response { - let state_id = ui - .make_persistent_id(egui::Id::new(&self.id_source)) - .with("OptionalIdComboBox"); + fn ui_inner( + self, + ui: &mut egui::Ui, + formatter: impl Fn(&Self) -> String, + f: impl FnOnce(Self, &mut egui::Ui, Vec, bool, bool) -> bool, + ) -> egui::Response { + let source = egui::Id::new(&self.id_source); + let state_id = ui.make_persistent_id(source).with("OptionalIdComboBox"); + let popup_id = ui.make_persistent_id(source).with("popup"); + let is_popup_open = ui.memory(|m| m.is_popup_open(popup_id)); let mut changed = false; let inner_response = egui::ComboBox::from_id_source(&self.id_source) .wrap(true) .width(ui.available_width() - ui.spacing().item_spacing.x) - .selected_text(if let Some(id) = *self.reference { - (self.formatter)(id) - } else { - "(None)".into() - }) + .selected_text(formatter(&self)) .show_ui(ui, |ui| { - let mut search_string = self - .retain_search + // Get cached search string and search matches from egui memory + let (mut search_string, search_matched_ids_lock) = is_popup_open .then(|| ui.data(|d| d.get_temp(state_id))) .flatten() - .unwrap_or_else(String::new); + .unwrap_or_else(|| { + ( + String::new(), + // We use a mutex here because if we just put the Vec directly into + // memory, egui will clone it every time we get it from memory + std::sync::Arc::new(parking_lot::Mutex::new( + self.id_iter.clone().collect_vec(), + )), + ) + }); + let mut search_matched_ids = search_matched_ids_lock.lock(); + let search_box_response = ui.add(egui::TextEdit::singleline(&mut search_string).hint_text("Search")); + + ui.add_space(ui.spacing().item_spacing.y); + + // If the combo box popup was not open the previous frame and was opened this + // frame, focus the search box + if !is_popup_open { + search_box_response.request_focus(); + } + let search_box_clicked = search_box_response.clicked() || search_box_response.secondary_clicked() || search_box_response.middle_clicked() || search_box_response.clicked_by(egui::PointerButton::Extra1) || search_box_response.clicked_by(egui::PointerButton::Extra2); - let matcher = fuzzy_matcher::skim::SkimMatcherV2::default(); - - egui::ScrollArea::vertical().show(ui, |ui| { - if ui - .selectable_label(self.reference.is_none(), "(None)") - .clicked() - { - *self.reference = None; - changed = true; - } - - let mut is_faint = true; - - for id in 0..self.len { - let formatted = (self.formatter)(id); - if matcher.fuzzy(&formatted, &search_string, false).is_none() { - continue; - } + // If the user edited the contents of the search box or if the data cache changed + // this frame, recalculate the search results + let search_needs_update = self.search_needs_update || search_box_response.changed(); + if search_needs_update { + let matcher = fuzzy_matcher::skim::SkimMatcherV2::default(); + search_matched_ids.clear(); + search_matched_ids.extend(self.id_iter.clone().filter(|id| { + matcher + .fuzzy(&(self.formatter)(*id), &search_string, false) + .is_some() + })); + } - ui.with_stripe(is_faint, |ui| { - if ui - .selectable_label(*self.reference == Some(id), formatted) - .clicked() - { - *self.reference = Some(id); - changed = true; - } - }); - is_faint = !is_faint; - } - }); - - ui.data_mut(|d| d.insert_temp(state_id, search_string)); + let button_height = ui.spacing().interact_size.y.max( + ui.text_style_height(&egui::TextStyle::Button) + + 2. * ui.spacing().button_padding.y, + ); + egui::ScrollArea::vertical().show_rows( + ui, + button_height, + search_matched_ids.len() + self.allow_none as usize, + |ui, range| { + let first_row_is_faint = range.clone().start % 2 != 0; + let show_none = self.allow_none && range.clone().start == 0; + let ids = range + .filter_map(|i| { + if self.allow_none { + (i != 0).then(|| search_matched_ids[i - 1]) + } else { + Some(search_matched_ids[i]) + } + }) + .collect_vec(); + changed = f(self, ui, ids, first_row_is_faint, show_none); + }, + ); + + // Save the search string and the search results back into egui memory + drop(search_matched_ids); + ui.data_mut(|d| d.insert_temp(state_id, (search_string, search_matched_ids_lock))); search_box_clicked }); @@ -276,12 +314,7 @@ where if inner_response.inner == Some(true) { // Force the combo box to stay open if the search box was clicked - ui.memory_mut(|m| { - m.open_popup( - ui.make_persistent_id(egui::Id::new(&self.id_source)) - .with("popup"), - ) - }); + ui.memory_mut(|m| m.open_popup(popup_id)); } else if inner_response.inner.is_none() && ui.data(|d| { d.get_temp::(state_id) @@ -299,6 +332,116 @@ where } } +impl<'a, I, H, F> OptionalIdComboBox<'a, Option, I, H, F> +where + I: Iterator + Clone, + H: std::hash::Hash, + F: Fn(usize) -> String, +{ + /// Enables or disables selecting the "(None)" option in the combo box. Defaults to `true`. + pub fn allow_none(mut self, value: bool) -> Self { + self.allow_none = value; + self + } +} + +impl<'a, I, H, F> egui::Widget for OptionalIdComboBox<'a, Option, I, H, F> +where + I: Iterator + Clone, + H: std::hash::Hash, + F: Fn(usize) -> String, +{ + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + let mut changed = false; + + self.ui_inner( + ui, + |this| { + if let Some(id) = *this.reference { + (this.formatter)(id) + } else { + "(None)".into() + } + }, + |this, ui, ids, first_row_is_faint, show_none| { + if show_none + && ui + .with_stripe(false, |ui| { + ui.selectable_label( + this.reference.is_none(), + ui.truncate_text("(None)"), + ) + }) + .inner + .clicked() + { + *this.reference = None; + changed = true; + } + + let mut is_faint = first_row_is_faint != show_none; + + for id in ids { + ui.with_stripe(is_faint, |ui| { + if ui + .selectable_label( + *this.reference == Some(id), + ui.truncate_text((this.formatter)(id)), + ) + .clicked() + { + *this.reference = Some(id); + changed = true; + } + }); + is_faint = !is_faint; + } + + changed + }, + ) + } +} + +impl<'a, I, H, F> egui::Widget for OptionalIdComboBox<'a, usize, I, H, F> +where + I: Iterator + Clone, + H: std::hash::Hash, + F: Fn(usize) -> String, +{ + fn ui(mut self, ui: &mut egui::Ui) -> egui::Response { + self.allow_none = false; + + let mut changed = false; + + self.ui_inner( + ui, + |this| (this.formatter)(*this.reference), + |this, ui, ids, first_row_is_faint, _| { + let mut is_faint = first_row_is_faint; + + for id in ids { + ui.with_stripe(is_faint, |ui| { + if ui + .selectable_label( + *this.reference == id, + ui.truncate_text((this.formatter)(id)), + ) + .clicked() + { + *this.reference = id; + changed = true; + } + }); + is_faint = !is_faint; + } + + changed + }, + ) + } +} + pub fn close_options_ui(ui: &mut egui::Ui, open: &mut bool, save: &mut bool) { ui.horizontal(|ui| { if ui.button("Ok").clicked() { diff --git a/crates/components/src/ui_ext.rs b/crates/components/src/ui_ext.rs index 669234ea..3d407e78 100644 --- a/crates/components/src/ui_ext.rs +++ b/crates/components/src/ui_ext.rs @@ -49,6 +49,18 @@ pub trait UiExt { /// Displays contents with a normal or faint background (useful for tables with striped rows). fn with_stripe(&mut self, faint: bool, f: impl FnOnce(&mut Self) -> R) -> InnerResponse; + + /// Displays contents with a normal or faint background as well as a little bit of horizontal + /// padding. + fn with_padded_stripe( + &mut self, + faint: bool, + f: impl FnOnce(&mut Self) -> R, + ) -> InnerResponse; + + /// Modifies the given `egui::WidgetText` to truncate when the text is too long to fit in the + /// current layout, rather than wrapping the text or expanding the layout. + fn truncate_text(&self, text: impl Into) -> egui::WidgetText; } impl UiExt for egui::Ui { @@ -114,4 +126,31 @@ impl UiExt for egui::Ui { } .show(self, f) } + + fn with_padded_stripe( + &mut self, + faint: bool, + f: impl FnOnce(&mut Self) -> R, + ) -> InnerResponse { + let frame = egui::containers::Frame::none() + .inner_margin(egui::Margin::symmetric(self.spacing().item_spacing.x, 0.)); + if faint { + frame.fill(self.visuals().faint_bg_color) + } else { + frame + } + .show(self, f) + } + + fn truncate_text(&self, text: impl Into) -> egui::WidgetText { + let mut job = Into::::into(text).into_layout_job( + self.style(), + egui::TextStyle::Body.into(), + self.layout().vertical_align(), + ); + job.wrap.max_width = self.available_width(); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + job.into() + } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index fa0553f6..7f197a57 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -77,6 +77,7 @@ pub struct UpdateState<'res> { pub toolbar: &'res mut ToolbarState, pub modified: ModifiedState, + pub modified_during_prev_frame: &'res mut bool, pub project_manager: &'res mut ProjectManager, pub git_revision: &'static str, @@ -94,6 +95,11 @@ pub struct ModifiedState { modified: std::rc::Rc>, #[cfg(target_arch = "wasm32")] modified: Arc, + + #[cfg(not(target_arch = "wasm32"))] + modified_this_frame: std::rc::Rc>, + #[cfg(target_arch = "wasm32")] + modified_this_frame: Arc, } #[cfg(not(target_arch = "wasm32"))] @@ -102,8 +108,17 @@ impl ModifiedState { self.modified.get() } + pub fn get_this_frame(&self) -> bool { + self.modified_this_frame.get() + } + pub fn set(&self, val: bool) { self.modified.set(val); + self.modified_this_frame.set(val); + } + + pub fn set_this_frame(&self, val: bool) { + self.modified_this_frame.set(val); } } @@ -113,9 +128,21 @@ impl ModifiedState { self.modified.load(std::sync::atomic::Ordering::Relaxed) } + pub fn get_this_frame(&self) -> bool { + self.modified_this_frame + .load(std::sync::atomic::Ordering::Relaxed) + } + pub fn set(&self, val: bool) { self.modified .store(val, std::sync::atomic::Ordering::Relaxed); + self.modified_this_frame + .store(val, std::sync::atomic::Ordering::Relaxed); + } + + pub fn set_this_frame(&self, val: bool) { + self.modified_this_frame + .store(val, std::sync::atomic::Ordering::Relaxed); } } @@ -155,6 +182,7 @@ impl<'res> UpdateState<'res> { global_config: self.global_config, toolbar: self.toolbar, modified: self.modified.clone(), + modified_during_prev_frame: self.modified_during_prev_frame, project_manager: self.project_manager, git_revision: self.git_revision, } @@ -178,6 +206,7 @@ impl<'res> UpdateState<'res> { global_config: self.global_config, toolbar: self.toolbar, modified: self.modified.clone(), + modified_during_prev_frame: self.modified_during_prev_frame, project_manager: self.project_manager, git_revision: self.git_revision, } diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index a415399a..a9712116 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -23,17 +23,36 @@ pub mod rpg { pub use crate::rmxp::*; pub use crate::shared::*; + pub trait DatabaseEntry + where + Self: Default, + { + fn default_with_id(id: usize) -> Self; + } + macro_rules! basic_container { - ($($parent:ident, $child:ident),* $(,)?) => { - $( - #[derive(Debug, Default)] - pub struct $parent { - pub data: Vec<$child>, - pub modified: bool, - } - )* - }; -} + ($($parent:ident, $child:ident),* $(,)?) => { + $( + #[derive(Debug, Default)] + pub struct $parent { + pub data: Vec<$child>, + pub modified: bool, + } + )* + }; + } + + macro_rules! database_entry { + ($($type:ident),* $(,)?) => { + $( + impl DatabaseEntry for $type { + fn default_with_id(id: usize) -> Self { + Self { id, ..Default::default() } + } + } + )* + }; + } basic_container! { Actors, Actor, @@ -51,6 +70,21 @@ pub mod rpg { Weapons, Weapon, } + database_entry! { + Actor, + Animation, + Armor, + Class, + CommonEvent, + Enemy, + Item, + Skill, + State, + Tileset, + Troop, + Weapon, + } + #[derive(Debug, Default)] pub struct MapInfos { pub data: std::collections::HashMap, diff --git a/crates/data/src/rgss_structs.rs b/crates/data/src/rgss_structs.rs index 3af04692..4782bb51 100644 --- a/crates/data/src/rgss_structs.rs +++ b/crates/data/src/rgss_structs.rs @@ -163,6 +163,11 @@ impl Table1 { self.xsize = xsize; } + pub fn resize_with_value(&mut self, xsize: usize, value: i16) { + self.data.resize(xsize, value); + self.xsize = xsize; + } + pub fn iter(&self) -> impl Iterator { self.data.iter() } diff --git a/crates/data/src/rmxp/enemy.rs b/crates/data/src/rmxp/enemy.rs index 6b2566cf..fc23e52a 100644 --- a/crates/data/src/rmxp/enemy.rs +++ b/crates/data/src/rmxp/enemy.rs @@ -54,13 +54,13 @@ pub struct Enemy { pub treasure_prob: i32, } -#[derive(Default, Debug, serde::Deserialize, serde::Serialize)] +#[derive(Debug, serde::Deserialize, serde::Serialize)] #[serde(rename = "RPG::Enemy::Action")] pub struct Action { - pub kind: i32, - pub basic: i32, - #[serde(with = "optional_id")] - pub skill_id: Option, + pub kind: Kind, + pub basic: Basic, + #[serde(with = "id")] + pub skill_id: usize, pub condition_turn_a: i32, pub condition_turn_b: i32, pub condition_hp: i32, @@ -69,3 +69,56 @@ pub struct Action { pub condition_switch_id: Option, pub rating: i32, } + +impl Default for Action { + fn default() -> Self { + Self { + kind: Kind::default(), + basic: Basic::default(), + skill_id: 0, + condition_turn_a: 0, + condition_turn_b: 1, + condition_hp: 100, + condition_level: 1, + condition_switch_id: None, + rating: 5, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +#[derive( + num_enum::TryFromPrimitive, + num_enum::IntoPrimitive, + strum::Display, + strum::EnumIter +)] +#[derive(serde::Deserialize, serde::Serialize)] +#[repr(u8)] +#[serde(into = "u8")] +#[serde(try_from = "u8")] +pub enum Kind { + #[default] + Basic = 0, + Skill = 1, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +#[derive( + num_enum::TryFromPrimitive, + num_enum::IntoPrimitive, + strum::Display, + strum::EnumIter +)] +#[derive(serde::Deserialize, serde::Serialize)] +#[repr(u8)] +#[serde(into = "u8")] +#[serde(try_from = "u8")] +pub enum Basic { + #[default] + Attack = 0, + Defend = 1, + Escape = 2, + #[strum(to_string = "Do Nothing")] + DoNothing = 3, +} diff --git a/crates/data/src/rmxp/item.rs b/crates/data/src/rmxp/item.rs index 629fb97f..e5a0c743 100644 --- a/crates/data/src/rmxp/item.rs +++ b/crates/data/src/rmxp/item.rs @@ -24,8 +24,8 @@ pub struct Item { pub name: String, pub icon_name: String, pub description: String, - pub scope: Scope, - pub occasion: Occasion, + pub scope: crate::rpg::Scope, + pub occasion: crate::rpg::Occasion, #[serde(with = "optional_id")] pub animation1_id: Option, #[serde(with = "optional_id")] @@ -57,57 +57,6 @@ pub struct Item { pub minus_state_set: Vec, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] -#[derive( - num_enum::TryFromPrimitive, - num_enum::IntoPrimitive, - strum::Display, - strum::EnumIter -)] -#[derive(serde::Deserialize, serde::Serialize)] -#[repr(u8)] -#[serde(into = "u8")] -#[serde(try_from = "u8")] -pub enum Scope { - #[default] - None = 0, - #[strum(to_string = "One Enemy")] - OneEnemy = 1, - #[strum(to_string = "All Enemies")] - AllEnemies = 2, - #[strum(to_string = "One Ally")] - OneAlly = 3, - #[strum(to_string = "All Allies")] - AllAllies = 4, - #[strum(to_string = "One Ally (HP 0)")] - OneAllyHP0 = 5, - #[strum(to_string = "All Allies (HP 0)")] - AllAlliesHP0 = 6, - #[strum(to_string = "The User")] - User = 7, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] -#[derive( - num_enum::TryFromPrimitive, - num_enum::IntoPrimitive, - strum::Display, - strum::EnumIter -)] -#[derive(serde::Deserialize, serde::Serialize)] -#[repr(u8)] -#[serde(into = "u8")] -#[serde(try_from = "u8")] -pub enum Occasion { - #[default] - Always = 0, - #[strum(to_string = "Only in battle")] - OnlyBattle = 1, - #[strum(to_string = "Only from the menu")] - OnlyMenu = 2, - Never = 3, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] #[derive( num_enum::TryFromPrimitive, @@ -126,12 +75,18 @@ pub enum ParameterType { MaxHP = 1, #[strum(to_string = "Max SP")] MaxSP = 2, - #[strum(to_string = "Strength")] + #[strum(to_string = "STR")] Str = 3, - #[strum(to_string = "Dexterity")] + #[strum(to_string = "DEX")] Dex = 4, - #[strum(to_string = "Agility")] + #[strum(to_string = "AGI")] Agi = 5, - #[strum(to_string = "Intelligence")] + #[strum(to_string = "INT")] Int = 6, } + +impl ParameterType { + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} diff --git a/crates/data/src/rmxp/skill.rs b/crates/data/src/rmxp/skill.rs index 191d81cd..8c539f20 100644 --- a/crates/data/src/rmxp/skill.rs +++ b/crates/data/src/rmxp/skill.rs @@ -25,8 +25,8 @@ pub struct Skill { #[serde(with = "optional_path")] pub icon_name: Path, pub description: String, - pub scope: i32, - pub occasion: Occasion, + pub scope: crate::rpg::Scope, + pub occasion: crate::rpg::Occasion, #[serde(with = "optional_id")] pub animation1_id: Option, #[serde(with = "optional_id")] @@ -53,24 +53,3 @@ pub struct Skill { #[serde(with = "id_vec")] pub minus_state_set: Vec, } - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] -#[derive( - num_enum::TryFromPrimitive, - num_enum::IntoPrimitive, - strum::Display, - strum::EnumIter -)] -#[derive(serde::Deserialize, serde::Serialize)] -#[repr(u8)] -#[serde(into = "u8")] -#[serde(try_from = "u8")] -pub enum Occasion { - #[default] - Always = 0, - #[strum(to_string = "Only in battle")] - OnlyBattle = 1, - #[strum(to_string = "Only from the menu")] - OnlyMenu = 2, - Never = 3, -} diff --git a/crates/data/src/shared/mod.rs b/crates/data/src/shared/mod.rs index 069bd4a8..6a99ba40 100644 --- a/crates/data/src/shared/mod.rs +++ b/crates/data/src/shared/mod.rs @@ -44,3 +44,54 @@ pub enum BlendMode { Add = 1, Subtract = 2, } + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +#[derive( + num_enum::TryFromPrimitive, + num_enum::IntoPrimitive, + strum::Display, + strum::EnumIter +)] +#[derive(serde::Deserialize, serde::Serialize)] +#[repr(u8)] +#[serde(into = "u8")] +#[serde(try_from = "u8")] +pub enum Scope { + #[default] + None = 0, + #[strum(to_string = "One Enemy")] + OneEnemy = 1, + #[strum(to_string = "All Enemies")] + AllEnemies = 2, + #[strum(to_string = "One Ally")] + OneAlly = 3, + #[strum(to_string = "All Allies")] + AllAllies = 4, + #[strum(to_string = "One Ally (HP 0)")] + OneAllyHP0 = 5, + #[strum(to_string = "All Allies (HP 0)")] + AllAlliesHP0 = 6, + #[strum(to_string = "The User")] + User = 7, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +#[derive( + num_enum::TryFromPrimitive, + num_enum::IntoPrimitive, + strum::Display, + strum::EnumIter +)] +#[derive(serde::Deserialize, serde::Serialize)] +#[repr(u8)] +#[serde(into = "u8")] +#[serde(try_from = "u8")] +pub enum Occasion { + #[default] + Always = 0, + #[strum(to_string = "Only in battle")] + OnlyBattle = 1, + #[strum(to_string = "Only from the menu")] + OnlyMenu = 2, + Never = 3, +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 2f513761..9f89c421 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -21,6 +21,7 @@ // it with Steamworks API by Valve Corporation, containing parts covered by // terms of the Steamworks API by Valve Corporation, the licensors of this // Program grant you additional permission to convey the resulting work. +#![feature(is_sorted)] pub type UpdateState<'res> = luminol_core::UpdateState<'res>; diff --git a/crates/ui/src/windows/actors.rs b/crates/ui/src/windows/actors.rs new file mode 100644 index 00000000..f3ba35c2 --- /dev/null +++ b/crates/ui/src/windows/actors.rs @@ -0,0 +1,733 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use itertools::Itertools; +use luminol_components::UiExt; + +use luminol_data::rpg::armor::Kind; + +#[derive(Default)] +pub struct Window { + selected_actor_name: Option, + previous_actor: Option, + + exp_view_is_total: bool, + exp_view_is_depersisted: bool, + + view: luminol_components::DatabaseView, +} + +impl Window { + pub fn new() -> Self { + Default::default() + } +} + +fn draw_graph( + ui: &mut egui::Ui, + actor: &luminol_data::rpg::Actor, + param: usize, + range: std::ops::RangeInclusive, + color: egui::Color32, +) -> egui::Response { + egui::Frame::canvas(ui.style()) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_height((ui.available_width() * 9.) / 16.); + let rect = ui.max_rect(); + let clip_rect = ui.clip_rect().intersect(rect); + if clip_rect.height() == 0. || clip_rect.width() == 0. { + return; + } + ui.set_clip_rect(clip_rect); + + let iter = (1..actor.parameters.ysize()).map(|i| { + rect.left_top() + + egui::vec2( + ((i - 1) as f32 / (actor.parameters.ysize() - 2) as f32) * rect.width(), + ((range + .end() + .saturating_sub(actor.parameters[(param, i)] as usize)) + as f32 + / range.end().saturating_sub(*range.start()) as f32) + * rect.height(), + ) + }); + + // Draw the filled part of the graph by drawing a trapezoid for each area horizontally + // between two points + let ppp = ui.ctx().pixels_per_point(); + ui.painter() + .extend( + iter.clone() + .tuple_windows() + .with_position() + .map(|(iter_pos, (p, q))| { + // Round the horizontal position of each point to the nearest pixel so egui doesn't + // try to anti-alias the vertical edges of the trapezoids + let p = if iter_pos == itertools::Position::First { + p + } else { + egui::pos2((p.x * ppp).round() / ppp, p.y) + }; + let q = if iter_pos == itertools::Position::Last { + q + } else { + egui::pos2((q.x * ppp).round() / ppp, q.y) + }; + + egui::Shape::convex_polygon( + vec![ + p, + q, + egui::pos2(q.x, rect.bottom()), + egui::pos2(p.x, rect.bottom()), + ], + color.gamma_multiply(0.25), + egui::Stroke::NONE, + ) + }), + ); + + // Draw the border of the graph + ui.painter().add(egui::Shape::line( + iter.collect_vec(), + egui::Stroke { width: 2., color }, + )); + }) + .response +} + +fn draw_exp(ui: &mut egui::Ui, actor: &luminol_data::rpg::Actor, total: &mut bool) { + let mut exp = [0f64; 99]; + + let p = actor.exp_inflation as f64 / 100. + 2.4; + for i in 1..99.min(actor.final_level as usize) { + exp[i] = exp[i - 1] + (actor.exp_basis as f64 * (((i + 4) as f64 / 5.).powf(p))).trunc(); + } + + ui.columns(2, |columns| { + if columns[0] + .selectable_label(!*total, "To Next Level") + .clicked() + { + *total = false; + } + if columns[1].selectable_label(*total, "Total").clicked() { + *total = true; + } + }); + + ui.group(|ui| { + egui::ScrollArea::vertical() + .min_scrolled_height(200.) + .show_rows( + ui, + ui.text_style_height(&egui::TextStyle::Body) + ui.spacing().item_spacing.y, + (actor.final_level - actor.initial_level + 1).clamp(0, 99) as usize, + |ui, range| { + ui.set_width(ui.available_width()); + + for (pos, i) in range.with_position() { + ui.with_padded_stripe(i % 2 != 0, |ui| { + let i = i + actor.initial_level.max(1) as usize - 1; + + ui.horizontal(|ui| { + ui.style_mut().wrap = Some(true); + + ui.with_layout( + egui::Layout { + main_dir: egui::Direction::RightToLeft, + ..*ui.layout() + }, + |ui| { + ui.add( + egui::Label::new(if *total { + exp[i].to_string() + } else if matches!( + pos, + itertools::Position::Last + | itertools::Position::Only + ) { + "(None)".into() + } else { + (exp[i + 1] - exp[i]).to_string() + }) + .truncate(true), + ); + + ui.with_layout( + egui::Layout { + main_dir: egui::Direction::LeftToRight, + ..*ui.layout() + }, + |ui| { + ui.add( + egui::Label::new(format!("Level {}", i + 1)) + .truncate(true), + ); + }, + ); + }, + ); + }); + }); + } + }, + ); + }); +} + +impl luminol_core::Window for Window { + fn name(&self) -> String { + if let Some(name) = &self.selected_actor_name { + format!("Editing actor {:?}", name) + } else { + "Actor Editor".into() + } + } + + fn id(&self) -> egui::Id { + egui::Id::new("actor_editor") + } + + fn requires_filesystem(&self) -> bool { + true + } + + fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + update_state: &mut luminol_core::UpdateState<'_>, + ) { + let mut actors = update_state.data.actors(); + let mut classes = update_state.data.classes(); + let weapons = update_state.data.weapons(); + let armors = update_state.data.armors(); + + let mut modified = false; + + self.selected_actor_name = None; + + let response = egui::Window::new(self.name()) + .id(self.id()) + .default_width(500.) + .open(open) + .show(ctx, |ui| { + self.view.show( + ui, + update_state, + "Actors", + &mut actors.data, + |actor| format!("{:0>4}: {}", actor.id + 1, actor.name), + |ui, actors, id| { + let actor = &mut actors[id]; + self.selected_actor_name = Some(actor.name.clone()); + + ui.with_padded_stripe(false, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Name", + egui::TextEdit::singleline(&mut actor.name) + .desired_width(f32::INFINITY), + )) + .changed(); + }); + + ui.with_padded_stripe(true, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Class", + luminol_components::OptionalIdComboBox::new( + update_state, + (actor.id, "class"), + &mut actor.class_id, + 0..classes.data.len(), + |id| { + classes.data.get(id).map_or_else( + || "".into(), + |c| format!("{:0>4}: {}", id + 1, c.name), + ) + }, + ), + )) + .changed(); + }); + + if let Some(class) = classes.data.get_mut(actor.class_id) { + if !class.weapon_set.is_sorted() { + class.weapon_set.sort_unstable(); + } + if !class.armor_set.is_sorted() { + class.armor_set.sort_unstable(); + } + } + let class = classes.data.get(actor.class_id); + + ui.with_padded_stripe(false, |ui| { + ui.add(luminol_components::Field::new( + "Starting Weapon", + |ui: &mut egui::Ui| { + egui::Frame::none() + .show(ui, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add( + luminol_components::OptionalIdComboBox::new( + update_state, + (actor.id, "weapon_id"), + &mut actor.weapon_id, + class + .map_or_else( + Default::default, + |c| { + c.weapon_set.iter().copied() + }, + ) + .filter(|id| { + (0..weapons.data.len()) + .contains(id) + }), + |id| { + weapons.data.get(id).map_or_else( + || "".into(), + |w| { + format!( + "{:0>4}: {}", + id + 1, + w.name + ) + }, + ) + }, + ), + ) + .changed(); + modified |= columns[1] + .checkbox(&mut actor.weapon_fix, "Fixed") + .changed(); + }); + }) + .response + }, + )); + }); + + ui.with_padded_stripe(true, |ui| { + ui.add(luminol_components::Field::new( + "Starting Shield", + |ui: &mut egui::Ui| { + egui::Frame::none() + .show(ui, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add( + luminol_components::OptionalIdComboBox::new( + update_state, + (actor.id, "armor1_id"), + &mut actor.armor1_id, + class + .map_or_else( + Default::default, + |c| c.armor_set.iter().copied(), + ) + .filter(|id| { + (0..armors.data.len()) + .contains(id) + && armors + .data + .get(*id) + .is_some_and(|a| { + matches!( + a.kind, + Kind::Shield + ) + }) + }), + |id| { + armors.data.get(id).map_or_else( + || "".into(), + |a| { + format!( + "{:0>4}: {}", + id + 1, + a.name, + ) + }, + ) + }, + ), + ) + .changed(); + modified |= columns[1] + .checkbox(&mut actor.armor1_fix, "Fixed") + .changed(); + }); + }) + .response + }, + )); + }); + + ui.with_padded_stripe(false, |ui| { + ui.add(luminol_components::Field::new( + "Starting Helmet", + |ui: &mut egui::Ui| { + egui::Frame::none() + .show(ui, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add( + luminol_components::OptionalIdComboBox::new( + update_state, + (actor.id, "armor2_id"), + &mut actor.armor2_id, + class + .map_or_else( + Default::default, + |c| c.armor_set.iter().copied(), + ) + .filter(|id| { + (0..armors.data.len()) + .contains(id) + && armors + .data + .get(*id) + .is_some_and(|a| { + matches!( + a.kind, + Kind::Helmet + ) + }) + }), + |id| { + armors.data.get(id).map_or_else( + || "".into(), + |a| { + format!( + "{:0>4}: {}", + id + 1, + a.name, + ) + }, + ) + }, + ), + ) + .changed(); + modified |= columns[1] + .checkbox(&mut actor.armor2_fix, "Fixed") + .changed(); + }); + }) + .response + }, + )); + }); + + ui.with_padded_stripe(true, |ui| { + ui.add(luminol_components::Field::new( + "Starting Body Armor", + |ui: &mut egui::Ui| { + egui::Frame::none() + .show(ui, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add( + luminol_components::OptionalIdComboBox::new( + update_state, + (actor.id, "armor3_id"), + &mut actor.armor3_id, + class + .map_or_else( + Default::default, + |c| c.armor_set.iter().copied(), + ) + .filter(|id| { + (0..armors.data.len()) + .contains(id) + && armors + .data + .get(*id) + .is_some_and(|a| { + matches!( + a.kind, + Kind::BodyArmor + ) + }) + }), + |id| { + armors.data.get(id).map_or_else( + || "".into(), + |a| { + format!( + "{:0>4}: {}", + id + 1, + a.name, + ) + }, + ) + }, + ), + ) + .changed(); + modified |= columns[1] + .checkbox(&mut actor.armor3_fix, "Fixed") + .changed(); + }); + }) + .response + }, + )); + }); + + ui.with_padded_stripe(false, |ui| { + ui.add(luminol_components::Field::new( + "Starting Accessory", + |ui: &mut egui::Ui| { + egui::Frame::none() + .show(ui, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add( + luminol_components::OptionalIdComboBox::new( + update_state, + (actor.id, "armor4_id"), + &mut actor.armor4_id, + class + .map_or_else( + Default::default, + |c| c.armor_set.iter().copied(), + ) + .filter(|id| { + (0..armors.data.len()) + .contains(id) + && armors + .data + .get(*id) + .is_some_and(|a| { + matches!( + a.kind, + Kind::Accessory + ) + }) + }), + |id| { + armors.data.get(id).map_or_else( + || "".into(), + |a| { + format!( + "{:0>4}: {}", + id + 1, + a.name, + ) + }, + ) + }, + ), + ) + .changed(); + modified |= columns[1] + .checkbox(&mut actor.armor4_fix, "Fixed") + .changed(); + }); + }) + .response + }, + )); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Initial Level", + egui::Slider::new(&mut actor.initial_level, 1..=99), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Final Level", + egui::Slider::new( + &mut actor.final_level, + actor.initial_level..=99, + ), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + // Forget whether the collapsing header was open from the last time + // the editor was open + let ui_id = ui.make_persistent_id("exp_collapsing_header"); + if !self.exp_view_is_depersisted { + self.exp_view_is_depersisted = true; + if let Some(h) = + egui::collapsing_header::CollapsingState::load(ui.ctx(), ui_id) + { + h.remove(ui.ctx()); + } + ui.ctx().animate_bool_with_time(ui_id, false, 0.); + } + + egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + ui_id, + false, + ) + .show_header(ui, |ui| { + ui.with_cross_justify(|ui| { + ui.label("EXP Curve"); + }); + }) + .body(|ui| { + draw_exp(ui, actor, &mut self.exp_view_is_total); + ui.add_space(ui.spacing().item_spacing.y); + }); + + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "EXP Curve Basis", + egui::Slider::new(&mut actor.exp_basis, 10..=50), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "EXP Curve Inflation", + egui::Slider::new(&mut actor.exp_inflation, 10..=50), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + columns[0].add(luminol_components::Field::new( + "Max HP", + |ui: &mut egui::Ui| { + draw_graph( + ui, + actor, + 0, + 1..=9999, + egui::Color32::from_rgb(204, 0, 0), + ) + }, + )); + + columns[1].add(luminol_components::Field::new( + "Max SP", + |ui: &mut egui::Ui| { + draw_graph( + ui, + actor, + 1, + 1..=9999, + egui::Color32::from_rgb(245, 123, 0), + ) + }, + )); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + columns[0].add(luminol_components::Field::new( + "STR", + |ui: &mut egui::Ui| { + draw_graph( + ui, + actor, + 2, + 1..=999, + egui::Color32::from_rgb(237, 213, 0), + ) + }, + )); + + columns[1].add(luminol_components::Field::new( + "DEX", + |ui: &mut egui::Ui| { + draw_graph( + ui, + actor, + 3, + 1..=999, + egui::Color32::from_rgb(116, 210, 22), + ) + }, + )); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + columns[0].add(luminol_components::Field::new( + "AGI", + |ui: &mut egui::Ui| { + draw_graph( + ui, + actor, + 4, + 1..=999, + egui::Color32::from_rgb(52, 101, 164), + ) + }, + )); + + columns[1].add(luminol_components::Field::new( + "INT", + |ui: &mut egui::Ui| { + draw_graph( + ui, + actor, + 5, + 1..=999, + egui::Color32::from_rgb(117, 80, 123), + ) + }, + )); + }); + }); + + self.previous_actor = Some(actor.id); + }, + ) + }); + + if response.is_some_and(|ir| ir.inner.is_some_and(|ir| ir.inner.modified)) { + modified = true; + } + + if modified { + update_state.modified.set(true); + actors.modified = true; + } + } +} diff --git a/crates/ui/src/windows/armor.rs b/crates/ui/src/windows/armor.rs new file mode 100644 index 00000000..e1d0eb50 --- /dev/null +++ b/crates/ui/src/windows/armor.rs @@ -0,0 +1,264 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use luminol_components::UiExt; + +#[derive(Default)] +pub struct Window { + selected_armor_name: Option, + previous_armor: Option, + + view: luminol_components::DatabaseView, +} + +impl Window { + pub fn new() -> Self { + Default::default() + } +} + +impl luminol_core::Window for Window { + fn name(&self) -> String { + if let Some(name) = &self.selected_armor_name { + format!("Editing armor {:?}", name) + } else { + "Armor Editor".into() + } + } + + fn id(&self) -> egui::Id { + egui::Id::new("armor_editor") + } + + fn requires_filesystem(&self) -> bool { + true + } + + fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + update_state: &mut luminol_core::UpdateState<'_>, + ) { + let mut armors = update_state.data.armors(); + let system = update_state.data.system(); + let states = update_state.data.states(); + + let mut modified = false; + + self.selected_armor_name = None; + + let response = egui::Window::new(self.name()) + .id(self.id()) + .default_width(500.) + .open(open) + .show(ctx, |ui| { + self.view.show( + ui, + update_state, + "Armor", + &mut armors.data, + |armor| format!("{:0>4}: {}", armor.id + 1, armor.name), + |ui, armors, id| { + let armor = &mut armors[id]; + self.selected_armor_name = Some(armor.name.clone()); + + ui.with_padded_stripe(false, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Name", + egui::TextEdit::singleline(&mut armor.name) + .desired_width(f32::INFINITY), + )) + .changed(); + + modified |= ui + .add(luminol_components::Field::new( + "Description", + egui::TextEdit::multiline(&mut armor.description) + .desired_width(f32::INFINITY), + )) + .changed(); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Kind", + luminol_components::EnumComboBox::new( + (armor.id, "kind"), + &mut armor.kind, + ), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Auto State", + luminol_components::OptionalIdComboBox::new( + update_state, + (armor.id, "auto_state"), + &mut armor.auto_state_id, + 0..states.data.len(), + |id| { + states.data.get(id).map_or_else( + || "".into(), + |s| format!("{:0>4}: {}", id + 1, s.name), + ) + }, + ), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(4, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Price", + egui::DragValue::new(&mut armor.price) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "EVA", + egui::DragValue::new(&mut armor.eva) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[2] + .add(luminol_components::Field::new( + "PDEF", + egui::DragValue::new(&mut armor.pdef) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[3] + .add(luminol_components::Field::new( + "MDEF", + egui::DragValue::new(&mut armor.mdef) + .clamp_range(0..=i32::MAX), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(4, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "STR+", + egui::DragValue::new(&mut armor.str_plus), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "DEX+", + egui::DragValue::new(&mut armor.dex_plus), + )) + .changed(); + + modified |= columns[2] + .add(luminol_components::Field::new( + "AGI+", + egui::DragValue::new(&mut armor.agi_plus), + )) + .changed(); + + modified |= columns[3] + .add(luminol_components::Field::new( + "INT+", + egui::DragValue::new(&mut armor.int_plus), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + let mut selection = luminol_components::IdVecSelection::new( + update_state, + (armor.id, "guard_element_set"), + &mut armor.guard_element_set, + 1..system.elements.len(), + |id| { + system.elements.get(id).map_or_else( + || "".into(), + |e| format!("{id:0>4}: {}", e), + ) + }, + ); + if self.previous_armor != Some(armor.id) { + selection.clear_search(); + } + modified |= columns[0] + .add(luminol_components::Field::new( + "Element Defense", + selection, + )) + .changed(); + + let mut selection = luminol_components::IdVecSelection::new( + update_state, + (armor.id, "guard_state_set"), + &mut armor.guard_state_set, + 0..states.data.len(), + |id| { + states.data.get(id).map_or_else( + || "".into(), + |s| format!("{:0>4}: {}", id + 1, s.name), + ) + }, + ); + if self.previous_armor != Some(armor.id) { + selection.clear_search(); + } + modified |= columns[1] + .add(luminol_components::Field::new("States", selection)) + .changed(); + }); + }); + + self.previous_armor = Some(armor.id); + }, + ) + }); + + if response.is_some_and(|ir| ir.inner.is_some_and(|ir| ir.inner.modified)) { + modified = true; + } + + if modified { + update_state.modified.set(true); + armors.modified = true; + } + } +} diff --git a/crates/ui/src/windows/classes.rs b/crates/ui/src/windows/classes.rs new file mode 100644 index 00000000..835b575a --- /dev/null +++ b/crates/ui/src/windows/classes.rs @@ -0,0 +1,310 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use luminol_components::UiExt; + +#[derive(Default)] +pub struct Window { + selected_class_name: Option, + previous_class: Option, + + collapsing_view: luminol_components::CollapsingView, + view: luminol_components::DatabaseView, +} + +impl Window { + pub fn new() -> Self { + Default::default() + } + + fn show_learning_header( + ui: &mut egui::Ui, + skills: &luminol_data::rpg::Skills, + learning: &luminol_data::rpg::class::Learning, + ) { + ui.label(format!( + "Lvl {}: {}", + learning.level, + skills.data.get(learning.skill_id).map_or("", |s| &s.name) + )); + } + + fn show_learning_body( + ui: &mut egui::Ui, + update_state: &luminol_core::UpdateState<'_>, + skills: &luminol_data::rpg::Skills, + class_id: usize, + learning: (usize, &mut luminol_data::rpg::class::Learning), + ) -> egui::Response { + let (learning_index, learning) = learning; + let mut modified = false; + + let mut response = egui::Frame::none() + .show(ui, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Level", + egui::Slider::new(&mut learning.level, 1..=99), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Skill", + luminol_components::OptionalIdComboBox::new( + update_state, + (class_id, learning_index, "skill_id"), + &mut learning.skill_id, + 0..skills.data.len(), + |id| { + skills.data.get(id).map_or_else( + || "".into(), + |s| format!("{:0>3}: {}", id + 1, s.name), + ) + }, + ), + )) + .changed(); + }); + }) + .response; + + if modified { + response.mark_changed(); + } + response + } +} + +impl luminol_core::Window for Window { + fn name(&self) -> String { + if let Some(name) = &self.selected_class_name { + format!("Editing class {:?}", name) + } else { + "Class Editor".into() + } + } + + fn id(&self) -> egui::Id { + egui::Id::new("class_editor") + } + + fn requires_filesystem(&self) -> bool { + true + } + + fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + update_state: &mut luminol_core::UpdateState<'_>, + ) { + let mut classes = update_state.data.classes(); + let system = update_state.data.system(); + let states = update_state.data.states(); + let skills = update_state.data.skills(); + let weapons = update_state.data.weapons(); + let armors = update_state.data.armors(); + + let mut modified = false; + + self.selected_class_name = None; + + let response = egui::Window::new(self.name()) + .id(self.id()) + .default_width(500.) + .open(open) + .show(ctx, |ui| { + self.view.show( + ui, + update_state, + "Classes", + &mut classes.data, + |class| format!("{:0>3}: {}", class.id + 1, class.name), + |ui, classes, id| { + let class = &mut classes[id]; + self.selected_class_name = Some(class.name.clone()); + + ui.with_padded_stripe(false, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Name", + egui::TextEdit::singleline(&mut class.name) + .desired_width(f32::INFINITY), + )) + .changed(); + }); + + ui.with_padded_stripe(true, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Position", + luminol_components::EnumComboBox::new( + (class.id, "position"), + &mut class.position, + ), + )) + .changed(); + }); + + ui.with_padded_stripe(false, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Skills", + |ui: &mut egui::Ui| { + if self.previous_class != Some(class.id) { + self.collapsing_view.clear_animations(); + } + self.collapsing_view.show( + ui, + class.id, + &mut class.learnings, + |ui, _i, learning| { + Self::show_learning_header(ui, &skills, learning) + }, + |ui, i, learning| { + Self::show_learning_body( + ui, + update_state, + &skills, + class.id, + (i, learning), + ) + }, + ) + }, + )) + .changed(); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + let mut selection = luminol_components::IdVecSelection::new( + update_state, + (class.id, "weapon_set"), + &mut class.weapon_set, + 0..weapons.data.len(), + |id| { + weapons.data.get(id).map_or_else( + || "".into(), + |w| format!("{:0>3}: {}", id + 1, w.name), + ) + }, + ); + if self.previous_class != Some(class.id) { + selection.clear_search(); + } + modified |= columns[0] + .add(luminol_components::Field::new( + "Equippable Weapons", + selection, + )) + .changed(); + + let mut selection = luminol_components::IdVecSelection::new( + update_state, + (class.id, "armor_set"), + &mut class.armor_set, + 0..armors.data.len(), + |id| { + armors.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>3}: {}", id + 1, a.name), + ) + }, + ); + if self.previous_class != Some(class.id) { + selection.clear_search(); + } + modified |= columns[1] + .add(luminol_components::Field::new( + "Equippable Armor", + selection, + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + class + .element_ranks + .resize_with_value(system.elements.len(), 3); + let mut selection = luminol_components::RankSelection::new( + update_state, + (class.id, "element_ranks"), + &mut class.element_ranks, + |id| { + system.elements.get(id + 1).map_or_else( + || "".into(), + |e| format!("{:0>3}: {}", id + 1, e), + ) + }, + ); + if self.previous_class != Some(class.id) { + selection.clear_search(); + } + modified |= columns[0] + .add(luminol_components::Field::new("Elements", selection)) + .changed(); + + class + .state_ranks + .resize_with_value(states.data.len() + 1, 3); + let mut selection = luminol_components::RankSelection::new( + update_state, + (class.id, "state_ranks"), + &mut class.state_ranks, + |id| { + states.data.get(id).map_or_else( + || "".into(), + |s| format!("{:0>3}: {}", id + 1, s.name), + ) + }, + ); + if self.previous_class != Some(class.id) { + selection.clear_search(); + } + modified |= columns[1] + .add(luminol_components::Field::new("States", selection)) + .changed(); + }); + }); + + self.previous_class = Some(class.id); + }, + ) + }); + + if response.is_some_and(|ir| ir.inner.is_some_and(|ir| ir.inner.modified)) { + modified = true; + } + + if modified { + update_state.modified.set(true); + classes.modified = true; + } + } +} diff --git a/crates/ui/src/windows/enemies.rs b/crates/ui/src/windows/enemies.rs new file mode 100644 index 00000000..aca4a2e5 --- /dev/null +++ b/crates/ui/src/windows/enemies.rs @@ -0,0 +1,662 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use luminol_components::UiExt; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +#[derive(strum::Display, strum::EnumIter)] +#[derive(serde::Deserialize, serde::Serialize)] +pub enum TreasureType { + #[default] + None, + Item, + Weapon, + Armor, +} + +#[derive(Default)] +pub struct Window { + selected_enemy_name: Option, + previous_enemy: Option, + + collapsing_view: luminol_components::CollapsingView, + view: luminol_components::DatabaseView, +} + +impl Window { + pub fn new() -> Self { + Default::default() + } + + fn show_action_header( + ui: &mut egui::Ui, + skills: &luminol_data::rpg::Skills, + action: &luminol_data::rpg::enemy::Action, + ) { + let mut conditions = Vec::with_capacity(4); + if action.condition_turn_a != 0 || action.condition_turn_b != 1 { + conditions.push(if action.condition_turn_b == 0 { + format!("Turn {}", action.condition_turn_a,) + } else if action.condition_turn_a == 0 { + format!("Turn {}x", action.condition_turn_b,) + } else if action.condition_turn_b == 1 { + format!("Turn {} + x", action.condition_turn_a,) + } else { + format!( + "Turn {} + {}x", + action.condition_turn_a, action.condition_turn_b, + ) + }) + } + if action.condition_hp < 100 { + conditions.push(format!("{}% HP", action.condition_hp,)); + } + if action.condition_level > 1 { + conditions.push(format!("Level {}", action.condition_level,)); + } + if let Some(id) = action.condition_switch_id { + conditions.push(format!("Switch {:0>4}", id + 1)); + } + + ui.label(format!( + "{}{}", + match action.kind { + luminol_data::rpg::enemy::Kind::Basic => { + action.basic.to_string() + } + luminol_data::rpg::enemy::Kind::Skill => { + skills + .data + .get(action.skill_id) + .map_or_else(|| "".into(), |s| s.name.clone()) + } + }, + if conditions.is_empty() { + String::new() + } else { + format!(": {}", conditions.join(", ")) + } + )); + } + + fn show_action_body( + ui: &mut egui::Ui, + update_state: &luminol_core::UpdateState<'_>, + system: &luminol_data::rpg::System, + skills: &luminol_data::rpg::Skills, + enemy_id: usize, + action: (usize, &mut luminol_data::rpg::enemy::Action), + ) -> egui::Response { + let (action_index, action) = action; + let mut modified = false; + + let mut response = egui::Frame::none() + .show(ui, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Turn Offset", + egui::DragValue::new(&mut action.condition_turn_a) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Turn Interval", + egui::DragValue::new(&mut action.condition_turn_b) + .clamp_range(0..=i32::MAX), + )) + .changed(); + }); + + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Max HP %", + egui::Slider::new(&mut action.condition_hp, 0..=100).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Min Level", + egui::Slider::new(&mut action.condition_level, 1..=99), + )) + .changed(); + }); + + modified |= ui + .add(luminol_components::Field::new( + "Switch", + luminol_components::OptionalIdComboBox::new( + update_state, + (enemy_id, action_index, "condition_switch_id"), + &mut action.condition_switch_id, + 0..system.switches.len(), + |id| { + system + .switches + .get(id) + .map_or_else(|| "".into(), |s| format!("{:0>4}: {}", id + 1, s)) + }, + ), + )) + .changed(); + + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Kind", + luminol_components::EnumComboBox::new( + (enemy_id, action_index, "kind"), + &mut action.kind, + ), + )) + .changed(); + + match action.kind { + luminol_data::rpg::enemy::Kind::Basic => { + modified |= columns[1] + .add(luminol_components::Field::new( + "Basic Type", + luminol_components::EnumComboBox::new( + (enemy_id, action_index, "basic"), + &mut action.basic, + ), + )) + .changed(); + } + luminol_data::rpg::enemy::Kind::Skill => { + modified |= columns[1] + .add(luminol_components::Field::new( + "Skill", + luminol_components::OptionalIdComboBox::new( + update_state, + (enemy_id, action_index, "skill_id"), + &mut action.skill_id, + 0..skills.data.len(), + |id| { + skills.data.get(id).map_or_else( + || "".into(), + |s| format!("{:0>4}: {}", id + 1, s.name), + ) + }, + ), + )) + .changed(); + } + } + }); + + modified |= ui + .add(luminol_components::Field::new( + "Rating", + egui::Slider::new(&mut action.rating, 1..=10), + )) + .changed(); + }) + .response; + + if modified { + response.mark_changed(); + } + response + } +} + +impl luminol_core::Window for Window { + fn name(&self) -> String { + if let Some(name) = &self.selected_enemy_name { + format!("Editing enemy {:?}", name) + } else { + "Enemy Editor".into() + } + } + + fn id(&self) -> egui::Id { + egui::Id::new("enemy_editor") + } + + fn requires_filesystem(&self) -> bool { + true + } + + fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + update_state: &mut luminol_core::UpdateState<'_>, + ) { + let mut enemies = update_state.data.enemies(); + let animations = update_state.data.animations(); + let system = update_state.data.system(); + let states = update_state.data.states(); + let skills = update_state.data.skills(); + let items = update_state.data.items(); + let weapons = update_state.data.weapons(); + let armors = update_state.data.armors(); + + let mut modified = false; + + self.selected_enemy_name = None; + + let response = egui::Window::new(self.name()) + .id(self.id()) + .default_width(500.) + .open(open) + .show(ctx, |ui| { + self.view.show( + ui, + update_state, + "Enemies", + &mut enemies.data, + |enemy| format!("{:0>4}: {}", enemy.id + 1, enemy.name), + |ui, enemies, id| { + let enemy = &mut enemies[id]; + self.selected_enemy_name = Some(enemy.name.clone()); + + ui.with_padded_stripe(false, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Name", + egui::TextEdit::singleline(&mut enemy.name) + .desired_width(f32::INFINITY), + )) + .changed(); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Attacker Animation", + luminol_components::OptionalIdComboBox::new( + update_state, + (enemy.id, "animation1_id"), + &mut enemy.animation1_id, + 0..animations.data.len(), + |id| { + animations.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Target Animation", + luminol_components::OptionalIdComboBox::new( + update_state, + (enemy.id, "animation2_id"), + &mut enemy.animation2_id, + 0..animations.data.len(), + |id| { + animations.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(4, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "EXP", + egui::DragValue::new(&mut enemy.exp) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Gold", + egui::DragValue::new(&mut enemy.gold) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[2] + .add(luminol_components::Field::new( + "Max HP", + egui::DragValue::new(&mut enemy.maxhp) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[3] + .add(luminol_components::Field::new( + "Max SP", + egui::DragValue::new(&mut enemy.maxsp) + .clamp_range(0..=i32::MAX), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(4, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "STR", + egui::DragValue::new(&mut enemy.str) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "DEX", + egui::DragValue::new(&mut enemy.dex) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[2] + .add(luminol_components::Field::new( + "AGI", + egui::DragValue::new(&mut enemy.agi) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[3] + .add(luminol_components::Field::new( + "INT", + egui::DragValue::new(&mut enemy.int) + .clamp_range(0..=i32::MAX), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(4, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "ATK", + egui::DragValue::new(&mut enemy.atk) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "EVA", + egui::DragValue::new(&mut enemy.eva) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[2] + .add(luminol_components::Field::new( + "PDEF", + egui::DragValue::new(&mut enemy.pdef) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[3] + .add(luminol_components::Field::new( + "MDEF", + egui::DragValue::new(&mut enemy.mdef) + .clamp_range(0..=i32::MAX), + )) + .changed(); + }); + }); + + let mut treasure_type = if enemy.item_id.is_some() { + TreasureType::Item + } else if enemy.weapon_id.is_some() { + TreasureType::Weapon + } else if enemy.armor_id.is_some() { + TreasureType::Armor + } else { + TreasureType::None + }; + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Treasure Type", + luminol_components::EnumComboBox::new( + (enemy.id, "treasure_type"), + &mut treasure_type, + ), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Treasure Probability", + egui::Slider::new(&mut enemy.treasure_prob, 0..=100), + )) + .changed(); + }); + + match treasure_type { + TreasureType::None => { + enemy.item_id = None; + enemy.weapon_id = None; + enemy.armor_id = None; + ui.add_enabled( + false, + luminol_components::Field::new( + "Treasure", + |ui: &mut egui::Ui| { + egui::ComboBox::from_id_source((enemy.id, "none")) + .wrap(true) + .width( + ui.available_width() + - ui.spacing().item_spacing.x, + ) + .show_ui(ui, |_ui| {}) + .response + }, + ), + ); + } + + TreasureType::Item => { + enemy.weapon_id = None; + enemy.armor_id = None; + if enemy.item_id.is_none() { + enemy.item_id = Some(0); + } + modified |= ui + .add(luminol_components::Field::new( + "Treasure", + luminol_components::OptionalIdComboBox::new( + update_state, + (enemy.id, "item_id"), + &mut enemy.item_id, + 0..items.data.len(), + |id| { + items.data.get(id).map_or_else( + || "".into(), + |i| format!("{:0>4}: {}", id + 1, i.name), + ) + }, + ) + .allow_none(false), + )) + .changed(); + } + + TreasureType::Weapon => { + enemy.item_id = None; + enemy.armor_id = None; + if enemy.weapon_id.is_none() { + enemy.weapon_id = Some(0); + } + modified |= ui + .add(luminol_components::Field::new( + "Treasure", + luminol_components::OptionalIdComboBox::new( + update_state, + (enemy.id, "weapon_id"), + &mut enemy.weapon_id, + 0..weapons.data.len(), + |id| { + weapons.data.get(id).map_or_else( + || "".into(), + |w| format!("{:0>4}: {}", id + 1, w.name), + ) + }, + ) + .allow_none(false), + )) + .changed(); + } + + TreasureType::Armor => { + enemy.item_id = None; + enemy.weapon_id = None; + if enemy.armor_id.is_none() { + enemy.armor_id = Some(0); + } + modified |= ui + .add(luminol_components::Field::new( + "Treasure", + luminol_components::OptionalIdComboBox::new( + update_state, + (enemy.id, "armor_id"), + &mut enemy.armor_id, + 0..armors.data.len(), + |id| { + armors.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ) + .allow_none(false), + )) + .changed(); + } + }; + }); + + ui.with_padded_stripe(false, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Actions", + |ui: &mut egui::Ui| { + if self.previous_enemy != Some(enemy.id) { + self.collapsing_view.clear_animations(); + } + self.collapsing_view.show( + ui, + enemy.id, + &mut enemy.actions, + |ui, _i, action| { + Self::show_action_header(ui, &skills, action) + }, + |ui, i, action| { + Self::show_action_body( + ui, + update_state, + &system, + &skills, + enemy.id, + (i, action), + ) + }, + ) + }, + )) + .changed(); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + enemy + .element_ranks + .resize_with_value(system.elements.len(), 3); + let mut selection = luminol_components::RankSelection::new( + update_state, + (enemy.id, "element_ranks"), + &mut enemy.element_ranks, + |id| { + system.elements.get(id + 1).map_or_else( + || "".into(), + |e| format!("{:0>4}: {}", id + 1, e), + ) + }, + ); + if self.previous_enemy != Some(enemy.id) { + selection.clear_search(); + } + modified |= columns[0] + .add(luminol_components::Field::new("Elements", selection)) + .changed(); + + enemy + .state_ranks + .resize_with_value(states.data.len() + 1, 3); + let mut selection = luminol_components::RankSelection::new( + update_state, + (enemy.id, "state_ranks"), + &mut enemy.state_ranks, + |id| { + states.data.get(id).map_or_else( + || "".into(), + |s| format!("{:0>4}: {}", id + 1, s.name), + ) + }, + ); + if self.previous_enemy != Some(enemy.id) { + selection.clear_search(); + } + modified |= columns[1] + .add(luminol_components::Field::new("States", selection)) + .changed(); + }); + }); + + self.previous_enemy = Some(enemy.id); + }, + ) + }); + + if response.is_some_and(|ir| ir.inner.is_some_and(|ir| ir.inner.modified)) { + modified = true; + } + + if modified { + update_state.modified.set(true); + enemies.modified = true; + } + } +} diff --git a/crates/ui/src/windows/items.rs b/crates/ui/src/windows/items.rs index ff88c433..5d165347 100644 --- a/crates/ui/src/windows/items.rs +++ b/crates/ui/src/windows/items.rs @@ -28,7 +28,6 @@ use luminol_components::UiExt; #[derive(Default)] pub struct Window { // ? Items ? - selected_item: usize, selected_item_name: Option, // ? Icon Graphic Picker ? @@ -37,7 +36,9 @@ pub struct Window { // ? Menu Sound Effect Picker ? _menu_se_picker: Option, - previous_selected_item: Option, + previous_item: Option, + + view: luminol_components::DatabaseView, } impl Window { @@ -69,120 +70,36 @@ impl luminol_core::Window for Window { open: &mut bool, update_state: &mut luminol_core::UpdateState<'_>, ) { - let change_maximum_text = "Change maximum..."; - - let p = update_state - .project_config - .as_ref() - .expect("project not loaded") - .project - .persistence_id; let mut items = update_state.data.items(); let animations = update_state.data.animations(); let common_events = update_state.data.common_events(); let system = update_state.data.system(); let states = update_state.data.states(); - self.selected_item = self.selected_item.min(items.data.len().saturating_sub(1)); - self.selected_item_name = items - .data - .get(self.selected_item) - .map(|item| item.name.clone()); let mut modified = false; - egui::Window::new(self.name()) - .id(egui::Id::new("item_editor")) + self.selected_item_name = None; + + let response = egui::Window::new(self.name()) + .id(self.id()) .default_width(500.) .open(open) .show(ctx, |ui| { - let button_height = ui.spacing().interact_size.y.max( - ui.text_style_height(&egui::TextStyle::Button) - + 2. * ui.spacing().button_padding.y, - ); - let button_width = ui.spacing().interact_size.x.max( - ui.text_width(change_maximum_text, egui::TextStyle::Button) - + 2. * ui.spacing().button_padding.x, - ); - - egui::SidePanel::left(egui::Id::new("item_edit_sidepanel")).show_inside(ui, |ui| { - ui.with_right_margin(ui.spacing().window_margin.right, |ui| { - ui.with_cross_justify(|ui| { - ui.label("Items"); - egui::ScrollArea::both() - .id_source(p) - .min_scrolled_width(button_width + ui.spacing().item_spacing.x) - .max_height( - ui.available_height() - - button_height - - ui.spacing().item_spacing.y, - ) - .show_rows(ui, button_height, items.data.len(), |ui, rows| { - ui.set_width(ui.available_width()); - - let offset = rows.start; - for (id, item) in items.data[rows].iter_mut().enumerate() { - let id = id + offset; - - ui.with_stripe(id % 2 != 0, |ui| { - ui.style_mut().wrap = Some(false); - - let response = ui - .selectable_value( - &mut self.selected_item, - id, - format!("{:0>3}: {}", id, item.name), - ) - .interact(egui::Sense::click()); - - if response.clicked() { - response.request_focus(); - } - - // Reset this item if delete or backspace - // is pressed while this item is focused - if response.has_focus() - && ui.input(|i| { - i.key_down(egui::Key::Delete) - || i.key_down(egui::Key::Backspace) - }) - { - *item = Default::default(); - modified = true; - } - }); - } - }); - - if ui - .add(egui::Button::new(change_maximum_text).wrap(false)) - .clicked() - { - luminol_core::basic!( - update_state.toasts, - "`Change maximum...` button trigger" - ); - } - }); - }); - }); - - ui.with_left_margin(ui.spacing().window_margin.left, |ui| { - ui.with_cross_justify(|ui| { - egui::ScrollArea::vertical().id_source(p).show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.set_min_width( - 2. * (ui.spacing().slider_width + ui.spacing().interact_size.x) - + 3. * ui.spacing().item_spacing.x, - ); - - let Some(selected_item) = items.data.get_mut(self.selected_item) else { - return; - }; - + self.view.show( + ui, + update_state, + "Items", + &mut items.data, + |item| format!("{:0>4}: {}", item.id + 1, item.name), + |ui, items, id| { + let item = &mut items[id]; + self.selected_item_name = Some(item.name.clone()); + + ui.with_padded_stripe(false, |ui| { modified |= ui .add(luminol_components::Field::new( "Name", - egui::TextEdit::singleline(&mut selected_item.name) + egui::TextEdit::singleline(&mut item.name) .desired_width(f32::INFINITY), )) .changed(); @@ -190,289 +107,277 @@ impl luminol_core::Window for Window { modified |= ui .add(luminol_components::Field::new( "Description", - egui::TextEdit::multiline(&mut selected_item.description) + egui::TextEdit::multiline(&mut item.description) .desired_width(f32::INFINITY), )) .changed(); + }); - ui.with_stripe(true, |ui| { - ui.columns(2, |columns| { - modified |= columns[0] - .add(luminol_components::Field::new( - "Scope", - luminol_components::EnumComboBox::new( - (selected_item.id, "scope"), - &mut selected_item.scope, - ), - )) - .changed(); - - modified |= columns[1] - .add(luminol_components::Field::new( - "Occasion", - luminol_components::EnumComboBox::new( - (selected_item.id, "occasion"), - &mut selected_item.occasion, - ), - )) - .changed(); - }); + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Scope", + luminol_components::EnumComboBox::new( + (item.id, "scope"), + &mut item.scope, + ), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Occasion", + luminol_components::EnumComboBox::new( + (item.id, "occasion"), + &mut item.occasion, + ), + )) + .changed(); }); + }); - ui.with_stripe(false, |ui| { - ui.columns(2, |columns| { - modified |= columns[0] - .add(luminol_components::Field::new( - "User Animation", - luminol_components::OptionalIdComboBox::new( - (selected_item.id, "animation1_id"), - &mut selected_item.animation1_id, - animations.data.len(), - |id| { - animations.data.get(id).map_or_else( - || "".into(), - |a| format!("{id:0>3}: {}", a.name), - ) - }, - ), - )) - .changed(); - - modified |= columns[1] - .add(luminol_components::Field::new( - "Target Animation", - luminol_components::OptionalIdComboBox::new( - (selected_item.id, "animation2_id"), - &mut selected_item.animation2_id, - animations.data.len(), - |id| { - animations.data.get(id).map_or_else( - || "".into(), - |a| format!("{id:0>3}: {}", a.name), - ) - }, - ), - )) - .changed(); - }); + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "User Animation", + luminol_components::OptionalIdComboBox::new( + update_state, + (item.id, "animation1_id"), + &mut item.animation1_id, + 0..animations.data.len(), + |id| { + animations.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Target Animation", + luminol_components::OptionalIdComboBox::new( + update_state, + (item.id, "animation2_id"), + &mut item.animation2_id, + 0..animations.data.len(), + |id| { + animations.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ), + )) + .changed(); }); + }); - ui.with_stripe(true, |ui| { - ui.columns(2, |columns| { - modified |= columns[0] - .add(luminol_components::Field::new( - "Menu Use SE", - egui::Label::new("TODO"), - )) - .changed(); - - modified |= columns[1] - .add(luminol_components::Field::new( - "Common Event", - luminol_components::OptionalIdComboBox::new( - (selected_item.id, "common_event_id"), - &mut selected_item.common_event_id, - common_events.data.len(), - |id| { - common_events.data.get(id).map_or_else( - || "".into(), - |e| format!("{id:0>3}: {}", e.name), - ) - }, - ), - )) - .changed(); - }); + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Menu Use SE", + egui::Label::new("TODO"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Common Event", + luminol_components::OptionalIdComboBox::new( + update_state, + (item.id, "common_event_id"), + &mut item.common_event_id, + 0..common_events.data.len(), + |id| { + common_events.data.get(id).map_or_else( + || "".into(), + |e| format!("{:0>4}: {}", id + 1, e.name), + ) + }, + ), + )) + .changed(); }); + }); - ui.with_stripe(false, |ui| { - ui.columns(2, |columns| { - modified |= columns[0] - .add(luminol_components::Field::new( - "Price", - egui::DragValue::new(&mut selected_item.price) - .clamp_range(0..=i32::MAX), - )) - .changed(); - - modified |= columns[1] - .add(luminol_components::Field::new( - "Consumable", - egui::Checkbox::without_text( - &mut selected_item.consumable, - ), - )) - .changed(); - }); + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Price", + egui::DragValue::new(&mut item.price) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Consumable", + egui::Checkbox::without_text(&mut item.consumable), + )) + .changed(); }); + }); - ui.with_stripe(true, |ui| { - ui.columns(2, |columns| { - modified |= columns[0] - .add(luminol_components::Field::new( - "Parameter", - luminol_components::EnumComboBox::new( - "parameter_type", - &mut selected_item.parameter_type, - ), - )) - .changed(); - - modified |= columns[1] - .add_enabled( - !matches!( - selected_item.parameter_type, - luminol_data::rpg::item::ParameterType::None - ), - luminol_components::Field::new( - "Parameter Increment", - egui::DragValue::new( - &mut selected_item.parameter_points, - ) + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Parameter", + luminol_components::EnumComboBox::new( + "parameter_type", + &mut item.parameter_type, + ), + )) + .changed(); + + modified |= columns[1] + .add_enabled( + !item.parameter_type.is_none(), + luminol_components::Field::new( + "Parameter Increment", + egui::DragValue::new(&mut item.parameter_points) .clamp_range(0..=i32::MAX), - ), - ) - .changed(); - }); + ), + ) + .changed(); }); + }); - ui.with_stripe(false, |ui| { - ui.columns(2, |columns| { - modified |= columns[0] - .add(luminol_components::Field::new( - "Recover HP Rate", - egui::Slider::new( - &mut selected_item.recover_hp_rate, - 0..=100, - ) + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Recover HP %", + egui::Slider::new(&mut item.recover_hp_rate, 0..=100) .suffix("%"), - )) - .changed(); - - modified |= columns[1] - .add(luminol_components::Field::new( - "Recover HP", - egui::DragValue::new(&mut selected_item.recover_hp) - .clamp_range(0..=i32::MAX), - )) - .changed(); - }); + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Recover HP Points", + egui::DragValue::new(&mut item.recover_hp) + .clamp_range(0..=i32::MAX), + )) + .changed(); }); + }); - ui.with_stripe(true, |ui| { - ui.columns(2, |columns| { - modified |= columns[0] - .add(luminol_components::Field::new( - "Recover SP Rate", - egui::Slider::new( - &mut selected_item.recover_sp_rate, - 0..=100, - ) + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Recover SP %", + egui::Slider::new(&mut item.recover_sp_rate, 0..=100) .suffix("%"), - )) - .changed(); - - modified |= columns[1] - .add(luminol_components::Field::new( - "Recover SP", - egui::DragValue::new(&mut selected_item.recover_sp) - .clamp_range(0..=i32::MAX), - )) - .changed(); - }); + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Recover SP Points", + egui::DragValue::new(&mut item.recover_sp) + .clamp_range(0..=i32::MAX), + )) + .changed(); }); + }); - ui.with_stripe(false, |ui| { - ui.columns(2, |columns| { - modified |= columns[0] - .add(luminol_components::Field::new( - "Hit Rate", - egui::Slider::new(&mut selected_item.hit, 0..=100) - .suffix("%"), - )) - .changed(); - - modified |= columns[1] - .add(luminol_components::Field::new( - "Variance", - egui::Slider::new(&mut selected_item.variance, 0..=100) - .suffix("%"), - )) - .changed(); - }); + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Hit Rate", + egui::Slider::new(&mut item.hit, 0..=100).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Variance", + egui::Slider::new(&mut item.variance, 0..=100).suffix("%"), + )) + .changed(); }); + }); - ui.with_stripe(true, |ui| { - ui.columns(2, |columns| { - modified |= columns[0] - .add(luminol_components::Field::new( - "PDEF-F", - egui::Slider::new(&mut selected_item.pdef_f, 0..=100) - .suffix("%"), - )) - .changed(); - - modified |= columns[1] - .add(luminol_components::Field::new( - "MDEF-F", - egui::Slider::new(&mut selected_item.mdef_f, 0..=100) - .suffix("%"), - )) - .changed(); - }); + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "PDEF-F", + egui::Slider::new(&mut item.pdef_f, 0..=100).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "MDEF-F", + egui::Slider::new(&mut item.mdef_f, 0..=100).suffix("%"), + )) + .changed(); }); + }); - ui.with_stripe(false, |ui| { - ui.columns(2, |columns| { - let mut selection = luminol_components::IdVecSelection::new( - (selected_item.id, "element_set"), - &mut selected_item.element_set, - system.elements.len(), + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + let mut selection = luminol_components::IdVecSelection::new( + update_state, + (item.id, "element_set"), + &mut item.element_set, + 1..system.elements.len(), + |id| { + system.elements.get(id).map_or_else( + || "".into(), + |e| format!("{id:0>4}: {}", e), + ) + }, + ); + if self.previous_item != Some(item.id) { + selection.clear_search(); + } + modified |= columns[0] + .add(luminol_components::Field::new("Elements", selection)) + .changed(); + + let mut selection = + luminol_components::IdVecPlusMinusSelection::new( + update_state, + (item.id, "state_set"), + &mut item.plus_state_set, + &mut item.minus_state_set, + 0..states.data.len(), |id| { - system.elements.get(id).map_or_else( + states.data.get(id).map_or_else( || "".into(), - |e| format!("{id:0>3}: {}", e), + |s| format!("{:0>4}: {}", id + 1, s.name), ) }, ); - if self.previous_selected_item != Some(selected_item.id) { - selection.clear_search(); - } - modified |= columns[0] - .add(luminol_components::Field::new("Elements", selection)) - .changed(); - - let mut selection = - luminol_components::IdVecPlusMinusSelection::new( - (selected_item.id, "state_set"), - &mut selected_item.plus_state_set, - &mut selected_item.minus_state_set, - states.data.len(), - |id| { - states.data.get(id).map_or_else( - || "".into(), - |s| format!("{id:0>3}: {}", s.name), - ) - }, - ); - if self.previous_selected_item != Some(selected_item.id) { - selection.clear_search(); - } - modified |= columns[1] - .add(luminol_components::Field::new( - "State Change", - selection, - )) - .changed(); - }); + if self.previous_item != Some(item.id) { + selection.clear_search(); + } + modified |= columns[1] + .add(luminol_components::Field::new("State Change", selection)) + .changed(); }); }); - }); - }); + + self.previous_item = Some(item.id); + }, + ) }); - self.previous_selected_item = - (self.selected_item < items.data.len()).then_some(self.selected_item); + if response.is_some_and(|ir| ir.inner.is_some_and(|ir| ir.inner.modified)) { + modified = true; + } if modified { update_state.modified.set(true); diff --git a/crates/ui/src/windows/mod.rs b/crates/ui/src/windows/mod.rs index b619ddc7..e3172d70 100644 --- a/crates/ui/src/windows/mod.rs +++ b/crates/ui/src/windows/mod.rs @@ -24,9 +24,15 @@ /// The about window. pub mod about; +/// The actor editor. +pub mod actors; pub mod appearance; /// The archive manager for creating and extracting RGSSAD archives. pub mod archive_manager; +/// The armor editor. +pub mod armor; +/// The class editor. +pub mod classes; /// The common event editor. pub mod common_event_edit; /// Config window @@ -34,6 +40,8 @@ pub mod config_window; /// Playtest console #[cfg(not(target_arch = "wasm32"))] pub mod console; +/// The enemy editor. +pub mod enemies; /// The event editor. pub mod event_edit; pub mod global_config_window; @@ -49,5 +57,11 @@ pub mod new_project; pub mod reporter; /// The script editor pub mod script_edit; +/// The skill editor. +pub mod skills; /// The sound test. pub mod sound_test; +/// The state editor. +pub mod states; +/// The weapon editor. +pub mod weapons; diff --git a/crates/ui/src/windows/skills.rs b/crates/ui/src/windows/skills.rs new file mode 100644 index 00000000..0d194751 --- /dev/null +++ b/crates/ui/src/windows/skills.rs @@ -0,0 +1,367 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use luminol_components::UiExt; + +#[derive(Default)] +pub struct Window { + selected_skill_name: Option, + previous_skill: Option, + + view: luminol_components::DatabaseView, +} + +impl Window { + pub fn new() -> Self { + Default::default() + } +} + +impl luminol_core::Window for Window { + fn name(&self) -> String { + if let Some(name) = &self.selected_skill_name { + format!("Editing skill {:?}", name) + } else { + "Skill Editor".into() + } + } + + fn id(&self) -> egui::Id { + egui::Id::new("skill_editor") + } + + fn requires_filesystem(&self) -> bool { + true + } + + fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + update_state: &mut luminol_core::UpdateState<'_>, + ) { + let mut skills = update_state.data.skills(); + let animations = update_state.data.animations(); + let common_events = update_state.data.common_events(); + let system = update_state.data.system(); + let states = update_state.data.states(); + + let mut modified = false; + + self.selected_skill_name = None; + + let response = egui::Window::new(self.name()) + .id(self.id()) + .default_width(500.) + .open(open) + .show(ctx, |ui| { + self.view.show( + ui, + update_state, + "Skills", + &mut skills.data, + |skill| format!("{:0>4}: {}", skill.id + 1, skill.name), + |ui, skills, id| { + let skill = &mut skills[id]; + self.selected_skill_name = Some(skill.name.clone()); + + ui.with_padded_stripe(false, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Name", + egui::TextEdit::singleline(&mut skill.name) + .desired_width(f32::INFINITY), + )) + .changed(); + + modified |= ui + .add(luminol_components::Field::new( + "Description", + egui::TextEdit::multiline(&mut skill.description) + .desired_width(f32::INFINITY), + )) + .changed(); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Scope", + luminol_components::EnumComboBox::new( + (skill.id, "scope"), + &mut skill.scope, + ), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Occasion", + luminol_components::EnumComboBox::new( + (skill.id, "occasion"), + &mut skill.occasion, + ), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "User Animation", + luminol_components::OptionalIdComboBox::new( + update_state, + (skill.id, "animation1_id"), + &mut skill.animation1_id, + 0..animations.data.len(), + |id| { + animations.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Target Animation", + luminol_components::OptionalIdComboBox::new( + update_state, + (skill.id, "animation2_id"), + &mut skill.animation2_id, + 0..animations.data.len(), + |id| { + animations.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Menu Use SE", + egui::Label::new("TODO"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Common Event", + luminol_components::OptionalIdComboBox::new( + update_state, + (skill.id, "common_event_id"), + &mut skill.common_event_id, + 0..common_events.data.len(), + |id| { + common_events.data.get(id).map_or_else( + || "".into(), + |e| format!("{:0>4}: {}", id + 1, e.name), + ) + }, + ), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "SP Cost", + egui::DragValue::new(&mut skill.sp_cost) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Power", + egui::DragValue::new(&mut skill.power), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "ATK-F", + egui::Slider::new(&mut skill.atk_f, 0..=200).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "EVA-F", + egui::Slider::new(&mut skill.eva_f, 0..=100).suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "STR-F", + egui::Slider::new(&mut skill.str_f, 0..=100).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "DEX-F", + egui::Slider::new(&mut skill.dex_f, 0..=100).suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "AGI-F", + egui::Slider::new(&mut skill.agi_f, 0..=100).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "INT-F", + egui::Slider::new(&mut skill.int_f, 0..=100).suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Hit Rate", + egui::Slider::new(&mut skill.hit, 0..=100).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Variance", + egui::Slider::new(&mut skill.variance, 0..=100).suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "PDEF-F", + egui::Slider::new(&mut skill.pdef_f, 0..=100).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "MDEF-F", + egui::Slider::new(&mut skill.mdef_f, 0..=100).suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + let mut selection = luminol_components::IdVecSelection::new( + update_state, + (skill.id, "element_set"), + &mut skill.element_set, + 1..system.elements.len(), + |id| { + system.elements.get(id).map_or_else( + || "".into(), + |e| format!("{id:0>4}: {}", e), + ) + }, + ); + if self.previous_skill != Some(skill.id) { + selection.clear_search(); + } + modified |= columns[0] + .add(luminol_components::Field::new("Elements", selection)) + .changed(); + + let mut selection = + luminol_components::IdVecPlusMinusSelection::new( + update_state, + (skill.id, "state_set"), + &mut skill.plus_state_set, + &mut skill.minus_state_set, + 0..states.data.len(), + |id| { + states.data.get(id).map_or_else( + || "".into(), + |s| format!("{:0>4}: {}", id + 1, s.name), + ) + }, + ); + if self.previous_skill != Some(skill.id) { + selection.clear_search(); + } + modified |= columns[1] + .add(luminol_components::Field::new("State Change", selection)) + .changed(); + }); + }); + + self.previous_skill = Some(skill.id); + }, + ) + }); + + if response.is_some_and(|ir| ir.inner.is_some_and(|ir| ir.inner.modified)) { + modified = true; + } + + if modified { + update_state.modified.set(true); + skills.modified = true; + } + } +} diff --git a/crates/ui/src/windows/states.rs b/crates/ui/src/windows/states.rs new file mode 100644 index 00000000..08862cdd --- /dev/null +++ b/crates/ui/src/windows/states.rs @@ -0,0 +1,390 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use luminol_components::UiExt; + +#[derive(Default)] +pub struct Window { + selected_state_name: Option, + previous_state: Option, + + view: luminol_components::DatabaseView, +} + +impl Window { + pub fn new() -> Self { + Default::default() + } +} + +impl luminol_core::Window for Window { + fn name(&self) -> String { + if let Some(name) = &self.selected_state_name { + format!("Editing state {:?}", name) + } else { + "State Editor".into() + } + } + + fn id(&self) -> egui::Id { + egui::Id::new("state_editor") + } + + fn requires_filesystem(&self) -> bool { + true + } + + fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + update_state: &mut luminol_core::UpdateState<'_>, + ) { + let mut states = update_state.data.states(); + let animations = update_state.data.animations(); + let system = update_state.data.system(); + + let mut modified = false; + + self.selected_state_name = None; + + let response = egui::Window::new(self.name()) + .id(self.id()) + .default_width(500.) + .open(open) + .show(ctx, |ui| { + self.view.show( + ui, + update_state, + "States", + &mut states.data, + |state| format!("{:0>4}: {}", state.id + 1, state.name), + |ui, states, id| { + let state = &mut states[id]; + self.selected_state_name = Some(state.name.clone()); + + ui.with_padded_stripe(false, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Name", + egui::TextEdit::singleline(&mut state.name) + .desired_width(f32::INFINITY), + )) + .changed(); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Animation", + luminol_components::OptionalIdComboBox::new( + update_state, + (state.id, "animation_id"), + &mut state.animation_id, + 0..animations.data.len(), + |id| { + animations.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Restriction", + luminol_components::EnumComboBox::new( + (state.id, "restriction"), + &mut state.restriction, + ), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Nonresistance", + egui::Checkbox::without_text(&mut state.nonresistance), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Count as 0 HP", + egui::Checkbox::without_text(&mut state.zero_hp), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(3, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Can't Get EXP", + egui::Checkbox::without_text(&mut state.cant_get_exp), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Can't Evade", + egui::Checkbox::without_text(&mut state.cant_evade), + )) + .changed(); + + modified |= columns[2] + .add(luminol_components::Field::new( + "Slip Damage", + egui::Checkbox::without_text(&mut state.slip_damage), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Rating", + egui::DragValue::new(&mut state.rating).clamp_range(0..=10), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "EVA", + egui::DragValue::new(&mut state.eva), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Max HP %", + egui::Slider::new(&mut state.maxhp_rate, 0..=200) + .suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Max SP %", + egui::Slider::new(&mut state.maxsp_rate, 0..=200) + .suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "STR %", + egui::Slider::new(&mut state.str_rate, 0..=200).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "DEX %", + egui::Slider::new(&mut state.dex_rate, 0..=200).suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "AGI %", + egui::Slider::new(&mut state.agi_rate, 0..=200).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "INT %", + egui::Slider::new(&mut state.int_rate, 0..=200).suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Hit Rate %", + egui::Slider::new(&mut state.hit_rate, 0..=200).suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "ATK %", + egui::Slider::new(&mut state.atk_rate, 0..=200).suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "PDEF %", + egui::Slider::new(&mut state.pdef_rate, 0..=200) + .suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "MDEF %", + egui::Slider::new(&mut state.mdef_rate, 0..=200) + .suffix("%"), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Auto Release Probability", + egui::Slider::new(&mut state.auto_release_prob, 0..=100) + .suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Auto Release Interval", + egui::DragValue::new(&mut state.hold_turn) + .clamp_range(0..=i32::MAX), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Damage Release Probability", + egui::Slider::new(&mut state.shock_release_prob, 0..=100) + .suffix("%"), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Battle Only", + egui::Checkbox::without_text(&mut state.battle_only), + )) + .changed(); + }); + }); + + let mut state = std::mem::take(state); + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + let mut selection = luminol_components::IdVecSelection::new( + update_state, + (state.id, "guard_element_set"), + &mut state.guard_element_set, + 1..system.elements.len(), + |id| { + system.elements.get(id).map_or_else( + || "".into(), + |e| format!("{id:0>4}: {}", e), + ) + }, + ); + if self.previous_state != Some(state.id) { + selection.clear_search(); + } + modified |= columns[0] + .add(luminol_components::Field::new( + "Element Defense", + selection, + )) + .changed(); + + let mut selection = + luminol_components::IdVecPlusMinusSelection::new( + update_state, + (state.id, "state_set"), + &mut state.plus_state_set, + &mut state.minus_state_set, + 0..states.len(), + |id| { + if id == state.id { + format!("{:0>4}: {}", id + 1, state.name) + } else { + states.get(id).map_or_else( + || "".into(), + |s| format!("{:0>4}: {}", id + 1, s.name), + ) + } + }, + ); + if self.previous_state != Some(state.id) { + selection.clear_search(); + } + modified |= columns[1] + .add(luminol_components::Field::new("State Change", selection)) + .changed(); + }); + }); + states[id] = state; + + self.previous_state = Some(id); + }, + ) + }); + + if response.is_some_and(|ir| ir.inner.is_some_and(|ir| ir.inner.modified)) { + modified = true; + } + + if modified { + update_state.modified.set(true); + states.modified = true; + } + } +} diff --git a/crates/ui/src/windows/weapons.rs b/crates/ui/src/windows/weapons.rs new file mode 100644 index 00000000..b75ce381 --- /dev/null +++ b/crates/ui/src/windows/weapons.rs @@ -0,0 +1,272 @@ +// Copyright (C) 2023 Lily Lyons +// +// This file is part of Luminol. +// +// Luminol is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Luminol is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Luminol. If not, see . +// +// Additional permission under GNU GPL version 3 section 7 +// +// If you modify this Program, or any covered work, by linking or combining +// it with Steamworks API by Valve Corporation, containing parts covered by +// terms of the Steamworks API by Valve Corporation, the licensors of this +// Program grant you additional permission to convey the resulting work. + +use luminol_components::UiExt; + +#[derive(Default)] +pub struct Window { + selected_weapon_name: Option, + previous_weapon: Option, + + view: luminol_components::DatabaseView, +} + +impl Window { + pub fn new() -> Self { + Default::default() + } +} + +impl luminol_core::Window for Window { + fn name(&self) -> String { + if let Some(name) = &self.selected_weapon_name { + format!("Editing weapon {:?}", name) + } else { + "Weapon Editor".into() + } + } + + fn id(&self) -> egui::Id { + egui::Id::new("weapon_editor") + } + + fn requires_filesystem(&self) -> bool { + true + } + + fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + update_state: &mut luminol_core::UpdateState<'_>, + ) { + let mut weapons = update_state.data.weapons(); + let animations = update_state.data.animations(); + let system = update_state.data.system(); + let states = update_state.data.states(); + + let mut modified = false; + + self.selected_weapon_name = None; + + let response = egui::Window::new(self.name()) + .id(self.id()) + .default_width(500.) + .open(open) + .show(ctx, |ui| { + self.view.show( + ui, + update_state, + "Weapons", + &mut weapons.data, + |weapon| format!("{:0>4}: {}", weapon.id + 1, weapon.name), + |ui, weapons, id| { + let weapon = &mut weapons[id]; + self.selected_weapon_name = Some(weapon.name.clone()); + + ui.with_padded_stripe(false, |ui| { + modified |= ui + .add(luminol_components::Field::new( + "Name", + egui::TextEdit::singleline(&mut weapon.name) + .desired_width(f32::INFINITY), + )) + .changed(); + + modified |= ui + .add(luminol_components::Field::new( + "Description", + egui::TextEdit::multiline(&mut weapon.description) + .desired_width(f32::INFINITY), + )) + .changed(); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(2, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "User Animation", + luminol_components::OptionalIdComboBox::new( + update_state, + (weapon.id, "animation1_id"), + &mut weapon.animation1_id, + 0..animations.data.len(), + |id| { + animations.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "Target Animation", + luminol_components::OptionalIdComboBox::new( + update_state, + (weapon.id, "animation2_id"), + &mut weapon.animation2_id, + 0..animations.data.len(), + |id| { + animations.data.get(id).map_or_else( + || "".into(), + |a| format!("{:0>4}: {}", id + 1, a.name), + ) + }, + ), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(4, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "Price", + egui::DragValue::new(&mut weapon.price) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "ATK", + egui::DragValue::new(&mut weapon.atk) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[2] + .add(luminol_components::Field::new( + "PDEF", + egui::DragValue::new(&mut weapon.pdef) + .clamp_range(0..=i32::MAX), + )) + .changed(); + + modified |= columns[3] + .add(luminol_components::Field::new( + "MDEF", + egui::DragValue::new(&mut weapon.mdef) + .clamp_range(0..=i32::MAX), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(true, |ui| { + ui.columns(4, |columns| { + modified |= columns[0] + .add(luminol_components::Field::new( + "STR+", + egui::DragValue::new(&mut weapon.str_plus), + )) + .changed(); + + modified |= columns[1] + .add(luminol_components::Field::new( + "DEX+", + egui::DragValue::new(&mut weapon.dex_plus), + )) + .changed(); + + modified |= columns[2] + .add(luminol_components::Field::new( + "AGI+", + egui::DragValue::new(&mut weapon.agi_plus), + )) + .changed(); + + modified |= columns[3] + .add(luminol_components::Field::new( + "INT+", + egui::DragValue::new(&mut weapon.int_plus), + )) + .changed(); + }); + }); + + ui.with_padded_stripe(false, |ui| { + ui.columns(2, |columns| { + let mut selection = luminol_components::IdVecSelection::new( + update_state, + (weapon.id, "element_set"), + &mut weapon.element_set, + 1..system.elements.len(), + |id| { + system.elements.get(id).map_or_else( + || "".into(), + |e| format!("{id:0>4}: {}", e), + ) + }, + ); + if self.previous_weapon != Some(weapon.id) { + selection.clear_search(); + } + modified |= columns[0] + .add(luminol_components::Field::new("Elements", selection)) + .changed(); + + let mut selection = + luminol_components::IdVecPlusMinusSelection::new( + update_state, + (weapon.id, "state_set"), + &mut weapon.plus_state_set, + &mut weapon.minus_state_set, + 0..states.data.len(), + |id| { + states.data.get(id).map_or_else( + || "".into(), + |s| format!("{:0>4}: {}", id + 1, s.name), + ) + }, + ); + if self.previous_weapon != Some(weapon.id) { + selection.clear_search(); + } + modified |= columns[1] + .add(luminol_components::Field::new("State Change", selection)) + .changed(); + }); + }); + + self.previous_weapon = Some(weapon.id); + }, + ) + }); + + if response.is_some_and(|ir| ir.inner.is_some_and(|ir| ir.inner.modified)) { + modified = true; + } + + if modified { + update_state.modified.set(true); + weapons.modified = true; + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index a0f2256a..65da5319 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -60,6 +60,7 @@ pub struct App { toolbar: luminol_core::ToolbarState, modified: luminol_core::ModifiedState, + modified_during_prev_frame: bool, project_manager: luminol_core::ProjectManager, #[cfg(not(target_arch = "wasm32"))] @@ -256,6 +257,7 @@ impl App { toolbar: luminol_core::ToolbarState::default(), modified, + modified_during_prev_frame: false, project_manager: luminol_core::ProjectManager::new(&cc.egui_ctx), #[cfg(not(target_arch = "wasm32"))] @@ -313,6 +315,7 @@ impl luminol_eframe::App for App { global_config: &mut self.global_config, toolbar: &mut self.toolbar, modified: self.modified.clone(), + modified_during_prev_frame: &mut self.modified_during_prev_frame, project_manager: &mut self.project_manager, git_revision: crate::git_revision(), }; @@ -395,6 +398,9 @@ impl luminol_eframe::App for App { #[cfg(feature = "steamworks")] self.steamworks.update(); + self.modified_during_prev_frame = self.modified.get_this_frame(); + self.modified.set_this_frame(false); + // Call the exit handler if the user or the app requested to close the window. #[cfg(not(target_arch = "wasm32"))] if ctx.input(|i| i.viewport().close_requested()) && self.modified.get() { diff --git a/src/app/top_bar.rs b/src/app/top_bar.rs index 8a996fb8..04fcfb9e 100644 --- a/src/app/top_bar.rs +++ b/src/app/top_bar.rs @@ -170,11 +170,17 @@ impl TopBar { .add_window(luminol_ui::windows::map_picker::Window::default()); } - if ui.button("Items").clicked() { - update_state - .edit_windows - .add_window(luminol_ui::windows::items::Window::new()); - } + ui.add_enabled_ui(false, |ui| { + if ui.button("Tilesets [TODO]").clicked() { + todo!(); + } + }); + + ui.add_enabled_ui(false, |ui| { + if ui.button("Animations [TODO]").clicked() { + todo!(); + } + }); if ui.button("Common Events").clicked() { update_state @@ -193,6 +199,70 @@ impl TopBar { luminol_ui::windows::sound_test::Window::new(update_state.filesystem), ); } + + ui.add_enabled_ui(false, |ui| { + if ui.button("System [TODO]").clicked() { + todo!(); + } + }); + + ui.separator(); + + if ui.button("Items").clicked() { + update_state + .edit_windows + .add_window(luminol_ui::windows::items::Window::new()); + } + + if ui.button("Skills").clicked() { + update_state + .edit_windows + .add_window(luminol_ui::windows::skills::Window::new()); + } + + if ui.button("Weapons").clicked() { + update_state + .edit_windows + .add_window(luminol_ui::windows::weapons::Window::new()); + } + + if ui.button("Armor").clicked() { + update_state + .edit_windows + .add_window(luminol_ui::windows::armor::Window::new()); + } + + if ui.button("States").clicked() { + update_state + .edit_windows + .add_window(luminol_ui::windows::states::Window::new()); + } + + ui.separator(); + + if ui.button("Actors").clicked() { + update_state + .edit_windows + .add_window(luminol_ui::windows::actors::Window::new()); + } + + if ui.button("Classes").clicked() { + update_state + .edit_windows + .add_window(luminol_ui::windows::classes::Window::new()); + } + + if ui.button("Enemies").clicked() { + update_state + .edit_windows + .add_window(luminol_ui::windows::enemies::Window::new()); + } + + ui.add_enabled_ui(false, |ui| { + if ui.button("Troops [TODO]").clicked() { + todo!(); + } + }); }); });