diff --git a/Cargo.lock b/Cargo.lock index 2aef3ad0..5d438883 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3107,6 +3107,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 38b9f398..042116b1 100644 --- a/crates/components/Cargo.toml +++ b/crates/components/Cargo.toml @@ -47,5 +47,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 e51b2923..23115b34 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; @@ -148,10 +156,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 { @@ -198,7 +209,7 @@ where .selectable_label( std::mem::discriminant(self.reference) == std::mem::discriminant(&variant), - variant.to_string(), + ui.truncate_text(variant.to_string()), ) .clicked() { @@ -216,103 +227,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 }); @@ -320,12 +358,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) @@ -343,6 +376,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 9e7ed4ed..d9c6b981 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!(); + } + }); }); });