diff --git a/Cargo.lock b/Cargo.lock
index 98a7be87..284f3415 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1999,6 +1999,15 @@ dependencies = [
"slab",
]
+[[package]]
+name = "fuzzy-matcher"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
+dependencies = [
+ "thread_local",
+]
+
[[package]]
name = "gdk-pixbuf-sys"
version = "0.18.0"
@@ -3039,6 +3048,7 @@ dependencies = [
"color-eyre",
"egui",
"fragile",
+ "fuzzy-matcher",
"glam",
"indextree",
"itertools",
diff --git a/crates/components/Cargo.toml b/crates/components/Cargo.toml
index d2f46d72..055414fe 100644
--- a/crates/components/Cargo.toml
+++ b/crates/components/Cargo.toml
@@ -45,3 +45,5 @@ indextree = "4.6.0"
lexical-sort = "0.3.1"
fragile.workspace = true
+
+fuzzy-matcher = "0.3.7"
diff --git a/crates/components/src/id_vec.rs b/crates/components/src/id_vec.rs
new file mode 100644
index 00000000..928a9300
--- /dev/null
+++ b/crates/components/src/id_vec.rs
@@ -0,0 +1,489 @@
+// 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.
+
+#[derive(Default, Clone)]
+struct IdVecSelectionState {
+ pivot: Option,
+ hide_tooltip: bool,
+ search_string: String,
+}
+
+pub struct IdVecSelection<'a, H, F> {
+ id_source: H,
+ reference: &'a mut Vec,
+ len: usize,
+ formatter: F,
+ clear_search: bool,
+}
+
+pub struct IdVecPlusMinusSelection<'a, H, F> {
+ id_source: H,
+ plus: &'a mut Vec,
+ minus: &'a mut Vec,
+ len: usize,
+ formatter: F,
+ clear_search: bool,
+}
+
+impl<'a, H, F> IdVecSelection<'a, H, F>
+where
+ H: std::hash::Hash,
+ 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 {
+ Self {
+ id_source,
+ reference,
+ len,
+ formatter,
+ clear_search: false,
+ }
+ }
+
+ /// Clears the search box.
+ pub fn clear_search(&mut self) {
+ self.clear_search = true;
+ }
+}
+
+impl<'a, H, F> IdVecPlusMinusSelection<'a, H, F>
+where
+ H: std::hash::Hash,
+ F: Fn(usize) -> String,
+{
+ /// Creates a new widget for changing the contents of a pair of `id_vec`s.
+ pub fn new(
+ id_source: H,
+ plus: &'a mut Vec,
+ minus: &'a mut Vec,
+ len: usize,
+ formatter: F,
+ ) -> Self {
+ Self {
+ id_source,
+ plus,
+ minus,
+ len,
+ formatter,
+ clear_search: false,
+ }
+ }
+
+ /// Clears the search box.
+ pub fn clear_search(&mut self) {
+ self.clear_search = true;
+ }
+}
+
+impl<'a, H, F> egui::Widget for IdVecSelection<'a, H, F>
+where
+ H: std::hash::Hash,
+ F: Fn(usize) -> String,
+{
+ fn ui(self, ui: &mut egui::Ui) -> egui::Response {
+ if !self.reference.is_sorted() {
+ self.reference.sort_unstable();
+ }
+
+ 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();
+ if self.clear_search {
+ state.search_string = String::new();
+ }
+
+ let mut index = 0;
+ let mut clicked_id = None;
+
+ let matcher = fuzzy_matcher::skim::SkimMatcherV2::default();
+
+ let mut response = ui
+ .group(|ui| {
+ ui.with_layout(
+ egui::Layout {
+ cross_justify: true,
+ ..Default::default()
+ },
+ |ui| {
+ ui.set_width(ui.available_width());
+
+ 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;
+ }
+
+ let mut frame = egui::Frame::none();
+ if is_faint {
+ frame = frame.fill(ui.visuals().faint_bg_color);
+ }
+ is_faint = !is_faint;
+
+ frame.show(ui, |ui| {
+ if ui
+ .selectable_label(is_id_selected, (self.formatter)(id))
+ .clicked()
+ {
+ clicked_id = Some(id);
+ }
+ });
+ }
+ },
+ )
+ .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 old_len = self.reference.len();
+
+ // 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
+ };
+
+ if is_pivot_selected {
+ let mut index = self
+ .reference
+ .iter()
+ .position(|id| range.contains(id))
+ .unwrap_or_default();
+ for id in range {
+ let is_id_selected =
+ 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()
+ {
+ self.reference.push(id);
+ }
+ }
+ } else {
+ self.reference.retain(|id| {
+ !range.contains(id)
+ || matcher
+ .fuzzy(&(self.formatter)(*id), &state.search_string, false)
+ .is_none()
+ });
+ }
+ } else {
+ state.pivot = Some(clicked_id);
+ if let Some(position) = self.reference.iter().position(|x| *x == clicked_id) {
+ self.reference.remove(position);
+ } else {
+ self.reference.push(clicked_id);
+ }
+ }
+
+ 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");
+ });
+ }
+
+ ui.data_mut(|d| d.insert_temp(state_id, state));
+
+ response
+ }
+}
+
+impl<'a, H, F> egui::Widget for IdVecPlusMinusSelection<'a, H, F>
+where
+ H: std::hash::Hash,
+ 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();
+ }
+
+ 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();
+ if self.clear_search {
+ state.search_string = String::new();
+ }
+
+ 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_layout(
+ egui::Layout {
+ cross_justify: true,
+ ..Default::default()
+ },
+ |ui| {
+ ui.set_width(ui.available_width());
+
+ 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;
+ }
+
+ let mut frame = egui::Frame::none();
+ if is_faint {
+ frame = frame.fill(ui.visuals().faint_bg_color);
+ }
+ is_faint = !is_faint;
+
+ frame.show(ui, |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,
+ if is_id_plus {
+ format!("+ {label}")
+ } else if is_id_minus {
+ format!("‒ {label}")
+ } else {
+ label
+ },
+ )
+ .clicked()
+ {
+ clicked_id = Some(id);
+ }
+ });
+ }
+ },
+ )
+ .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 old_plus_len = self.plus.len();
+ let old_minus_len = self.minus.len();
+
+ // 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
+ };
+
+ if is_pivot_plus {
+ self.minus.retain(|id| {
+ !range.contains(id)
+ || matcher
+ .fuzzy(&(self.formatter)(*id), &state.search_string, false)
+ .is_none()
+ });
+ let mut plus_index = self
+ .plus
+ .iter()
+ .position(|id| range.contains(id))
+ .unwrap_or_default();
+ for id in range {
+ let is_id_plus = plus_index < old_plus_len
+ && 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()
+ {
+ 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()
+ });
+ let mut minus_index = self
+ .minus
+ .iter()
+ .position(|id| range.contains(id))
+ .unwrap_or_default();
+ for id in range {
+ let is_id_minus = minus_index < old_minus_len
+ && 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()
+ {
+ 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()
+ });
+ }
+ } else {
+ state.pivot = Some(clicked_id);
+ if let Some(position) = self.plus.iter().position(|x| *x == clicked_id) {
+ self.plus.remove(position);
+ if !self.minus.contains(&clicked_id) {
+ self.minus.push(clicked_id);
+ }
+ } else if let Some(position) = self.minus.iter().position(|x| *x == clicked_id) {
+ self.minus.remove(position);
+ } else {
+ self.plus.push(clicked_id);
+ }
+ }
+
+ 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");
+ });
+ }
+
+ 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 9ce22c94..1c69b545 100644
--- a/crates/components/src/lib.rs
+++ b/crates/components/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)]
/// Syntax highlighter
pub mod syntax_highlighting;
@@ -40,6 +41,9 @@ pub use command_view::CommandView;
mod filesystem_view;
pub use filesystem_view::FileSystemView;
+mod id_vec;
+pub use id_vec::{IdVecPlusMinusSelection, IdVecSelection};
+
pub struct EnumMenuButton<'e, T> {
current_value: &'e mut T,
id: egui::Id,
@@ -65,10 +69,7 @@ impl<'e, T: ToString + PartialEq + strum::IntoEnumIterator> egui::Widget for Enu
}
}
-pub struct Field
-where
- T: egui::Widget,
-{
+pub struct Field {
name: String,
widget: T,
}
@@ -96,11 +97,210 @@ where
T: egui::Widget,
{
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
- ui.vertical(|ui| {
- ui.label(format!("{}:", self.name));
- ui.add(self.widget);
- })
- .response
+ let mut changed = false;
+ let mut response = ui
+ .vertical(|ui| {
+ ui.add(egui::Label::new(format!("{}:", self.name)).truncate(true));
+ if ui.add(self.widget).changed() {
+ changed = true;
+ };
+ })
+ .response;
+ if changed {
+ response.mark_changed();
+ }
+ response
+ }
+}
+
+pub struct EnumComboBox<'a, H, T> {
+ id_source: H,
+ reference: &'a mut T,
+}
+
+impl<'a, H, T> EnumComboBox<'a, H, T>
+where
+ H: std::hash::Hash,
+{
+ /// Creates a combo box that can be used to change the variant of an enum that implements
+ /// `strum::IntoEnumIterator + ToString`.
+ pub fn new(id_source: H, reference: &'a mut T) -> Self {
+ Self {
+ id_source,
+ reference,
+ }
+ }
+}
+
+impl<'a, H, T> egui::Widget for EnumComboBox<'a, H, T>
+where
+ H: std::hash::Hash,
+ T: strum::IntoEnumIterator + ToString,
+{
+ fn ui(self, ui: &mut egui::Ui) -> egui::Response {
+ let mut changed = false;
+ let mut response = egui::ComboBox::from_id_source(&self.id_source)
+ .wrap(true)
+ .width(ui.available_width() - ui.spacing().item_spacing.x)
+ .selected_text(self.reference.to_string())
+ .show_ui(ui, |ui| {
+ for (i, variant) in T::iter().enumerate() {
+ let mut frame = egui::Frame::none();
+ if i % 2 != 0 {
+ frame = frame.fill(ui.visuals().faint_bg_color);
+ }
+ frame.show(ui, |ui| {
+ if ui
+ .selectable_label(
+ std::mem::discriminant(self.reference)
+ == std::mem::discriminant(&variant),
+ variant.to_string(),
+ )
+ .clicked()
+ {
+ *self.reference = variant;
+ changed = true;
+ }
+ });
+ }
+ })
+ .response;
+ if changed {
+ response.mark_changed();
+ }
+ response
+ }
+}
+
+pub struct OptionalIdComboBox<'a, H, F> {
+ id_source: H,
+ reference: &'a mut Option,
+ len: usize,
+ formatter: F,
+ retain_search: bool,
+}
+
+impl<'a, H, F> OptionalIdComboBox<'a, H, F>
+where
+ 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 {
+ Self {
+ id_source,
+ reference,
+ len,
+ formatter,
+ retain_search: 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");
+
+ 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()
+ })
+ .show_ui(ui, |ui| {
+ let mut search_string = self
+ .retain_search
+ .then(|| ui.data(|d| d.get_temp(state_id)))
+ .flatten()
+ .unwrap_or_else(String::new);
+ let search_box_response =
+ ui.add(egui::TextEdit::singleline(&mut search_string).hint_text("Search"));
+ 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;
+ }
+
+ let mut frame = egui::Frame::none();
+ if is_faint {
+ frame = frame.fill(ui.visuals().faint_bg_color);
+ }
+ is_faint = !is_faint;
+
+ frame.show(ui, |ui| {
+ if ui
+ .selectable_label(*self.reference == Some(id), formatted)
+ .clicked()
+ {
+ *self.reference = Some(id);
+ changed = true;
+ }
+ });
+ }
+ });
+
+ ui.data_mut(|d| d.insert_temp(state_id, search_string));
+
+ search_box_clicked
+ });
+ let mut response = inner_response.response;
+
+ 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"),
+ )
+ });
+ } else if inner_response.inner.is_none()
+ && ui.data(|d| {
+ d.get_temp::(state_id)
+ .is_some_and(|s| !s.is_empty())
+ })
+ {
+ // Clear the search box if the combo box is closed
+ ui.data_mut(|d| d.insert_temp(state_id, String::new()));
+ }
+
+ if changed {
+ response.mark_changed();
+ }
+ response
}
}
diff --git a/crates/core/src/toasts.rs b/crates/core/src/toasts.rs
index eac712bd..54387f0c 100644
--- a/crates/core/src/toasts.rs
+++ b/crates/core/src/toasts.rs
@@ -97,7 +97,7 @@ macro_rules! info {
($toasts:expr, $caption:expr $(,)?) => {{
let caption = String::from($caption);
$crate::tracing::info!("{caption}");
- $crate::Toasts::_i_inner(&mut $toasts, $caption);
+ $crate::Toasts::_i_inner(&mut $toasts, caption);
}};
}
diff --git a/crates/data/src/rmxp/item.rs b/crates/data/src/rmxp/item.rs
index 3ef8f634..629fb97f 100644
--- a/crates/data/src/rmxp/item.rs
+++ b/crates/data/src/rmxp/item.rs
@@ -126,8 +126,12 @@ pub enum ParameterType {
MaxHP = 1,
#[strum(to_string = "Max SP")]
MaxSP = 2,
+ #[strum(to_string = "Strength")]
Str = 3,
+ #[strum(to_string = "Dexterity")]
Dex = 4,
+ #[strum(to_string = "Agility")]
Agi = 5,
+ #[strum(to_string = "Intelligence")]
Int = 6,
}
diff --git a/crates/filesystem/src/trie.rs b/crates/filesystem/src/trie.rs
index 4b688b1c..a86656ba 100644
--- a/crates/filesystem/src/trie.rs
+++ b/crates/filesystem/src/trie.rs
@@ -33,7 +33,7 @@ impl<'a, T> std::iter::ExactSizeIterator for FileSystemTrieDirIter<'a, T> {
fn len(&self) -> usize {
match &self.0 {
FileSystemTrieDirIterInner::Direct(_, len) => *len,
- FileSystemTrieDirIterInner::Prefix(_) => 1,
+ FileSystemTrieDirIterInner::Prefix(iter) => iter.len(),
}
}
}
diff --git a/crates/ui/src/windows/items.rs b/crates/ui/src/windows/items.rs
index 177c46e4..f0bbc86d 100644
--- a/crates/ui/src/windows/items.rs
+++ b/crates/ui/src/windows/items.rs
@@ -23,36 +23,34 @@
// Program grant you additional permission to convey the resulting work.
/// Database - Items management window.
+#[derive(Default)]
pub struct Window {
// ? Items ?
- items: Vec,
selected_item: usize,
+ selected_item_name: Option,
// ? Icon Graphic Picker ?
_icon_picker: Option,
// ? Menu Sound Effect Picker ?
_menu_se_picker: Option,
+
+ previous_selected_item: Option,
}
impl Window {
- pub fn new(data_cache: &luminol_core::Data) -> Self {
- let items = data_cache.items().data.clone();
-
- Self {
- items,
- selected_item: 0,
-
- _icon_picker: None,
-
- _menu_se_picker: None,
- }
+ pub fn new() -> Self {
+ Default::default()
}
}
impl luminol_core::Window for Window {
fn name(&self) -> String {
- format!("Editing item {}", self.items[self.selected_item].name)
+ if let Some(name) = &self.selected_item_name {
+ format!("Editing item {:?}", name)
+ } else {
+ "Item Editor".into()
+ }
}
fn id(&self) -> egui::Id {
@@ -69,66 +67,501 @@ impl luminol_core::Window for Window {
open: &mut bool,
update_state: &mut luminol_core::UpdateState<'_>,
) {
- let _selected_item = &self.items[self.selected_item];
- let _animations = update_state.data.animations();
-
- let _common_events = update_state.data.common_events();
-
- /*#[allow(clippy::cast_sign_loss)]
- if animations
- .get(selected_item.animation1_id as usize)
- .is_none()
- {
- info.toasts.error(format!(
- "Tried to get an animation with an ID of `{}`, but it doesn't exist.",
- selected_item.animation1_id
- ));
- return;
- }*/
+ let change_maximum_text = "Change maximum...";
+
+ 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"))
- .default_width(480.)
+ .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(
+ egui::WidgetText::from(change_maximum_text)
+ .into_galley(ui, None, f32::INFINITY, egui::TextStyle::Button)
+ .galley
+ .rect
+ .width()
+ + 2. * ui.spacing().button_padding.x,
+ );
+
egui::SidePanel::left(egui::Id::new("item_edit_sidepanel")).show_inside(ui, |ui| {
- ui.label("Items");
- egui::ScrollArea::both().max_height(600.).show_rows(
- ui,
- ui.text_style_height(&egui::TextStyle::Body),
- self.items.len(),
- |ui, rows| {
- for (id, item) in self.items[rows].iter().enumerate() {
- ui.selectable_value(
- &mut self.selected_item,
- id,
- format!("{:0>3}: {}", id, item.name),
- );
- }
- },
- );
-
- if ui.button("Change maximum...").clicked() {
- eprintln!("`Change maximum...` button trigger");
- }
- });
- let selected_item = &mut self.items[self.selected_item];
- egui::Grid::new("item_edit_central_grid").show(ui, |ui| {
- ui.add(luminol_components::Field::new(
- "Name",
- egui::TextEdit::singleline(&mut selected_item.name),
- ));
-
- ui.end_row();
-
- ui.add(luminol_components::Field::new(
- "Description",
- egui::TextEdit::singleline(&mut selected_item.description),
- ));
- ui.end_row();
-
- egui::Grid::new("item_edit_central_left_grid").show(ui, |_ui| {});
+ egui::Frame::none()
+ .outer_margin(egui::Margin {
+ right: ui.spacing().window_margin.right,
+ ..egui::Margin::ZERO
+ })
+ .show(ui, |ui| {
+ ui.with_layout(
+ egui::Layout {
+ cross_justify: true,
+ ..Default::default()
+ },
+ |ui| {
+ ui.label("Items");
+ egui::ScrollArea::both()
+ .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;
+ let mut frame = egui::containers::Frame::none();
+ if id % 2 != 0 {
+ frame =
+ frame.fill(ui.visuals().faint_bg_color);
+ }
+
+ frame.show(ui, |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"
+ );
+ }
+ },
+ );
+ });
});
+
+ egui::Frame::none()
+ .outer_margin(egui::Margin {
+ left: ui.spacing().window_margin.left,
+ ..egui::Margin::ZERO
+ })
+ .show(ui, |ui| {
+ ui.with_layout(
+ egui::Layout {
+ cross_justify: true,
+ ..Default::default()
+ },
+ |ui| {
+ egui::ScrollArea::vertical().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;
+ };
+
+ modified |= ui
+ .add(luminol_components::Field::new(
+ "Name",
+ egui::TextEdit::singleline(&mut selected_item.name)
+ .desired_width(f32::INFINITY),
+ ))
+ .changed();
+
+ modified |= ui
+ .add(luminol_components::Field::new(
+ "Description",
+ egui::TextEdit::multiline(
+ &mut selected_item.description,
+ )
+ .desired_width(f32::INFINITY),
+ ))
+ .changed();
+
+ egui::Frame::none().fill(ui.visuals().faint_bg_color).show(
+ ui,
+ |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();
+ });
+ },
+ );
+
+ egui::Frame::none().show(ui, |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();
+ });
+ });
+
+ egui::Frame::none().fill(ui.visuals().faint_bg_color).show(
+ ui,
+ |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();
+ });
+ },
+ );
+
+ egui::Frame::none().show(ui, |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();
+ });
+ });
+
+ egui::Frame::none().fill(ui.visuals().faint_bg_color).show(
+ ui,
+ |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,
+ )
+ .clamp_range(0..=i32::MAX),
+ ),
+ )
+ .changed();
+ });
+ },
+ );
+
+ egui::Frame::none().show(ui, |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,
+ )
+ .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();
+ });
+ });
+
+ egui::Frame::none().fill(ui.visuals().faint_bg_color).show(
+ ui,
+ |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,
+ )
+ .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();
+ });
+ },
+ );
+
+ egui::Frame::none().show(ui, |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();
+ });
+ });
+
+ egui::Frame::none().fill(ui.visuals().faint_bg_color).show(
+ ui,
+ |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();
+ });
+ },
+ );
+
+ egui::Frame::none().show(ui, |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(),
+ |id| {
+ system.elements.get(id).map_or_else(
+ || "".into(),
+ |e| format!("{id:0>3}: {}", e),
+ )
+ },
+ );
+ 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();
+ });
+ });
+ });
+ },
+ );
+ });
});
+
+ self.previous_selected_item =
+ (self.selected_item < items.data.len()).then_some(self.selected_item);
+
+ if modified {
+ update_state.modified.set(true);
+ items.modified = true;
+ }
}
}
diff --git a/src/app/top_bar.rs b/src/app/top_bar.rs
index 24dcca77..8a996fb8 100644
--- a/src/app/top_bar.rs
+++ b/src/app/top_bar.rs
@@ -173,7 +173,7 @@ impl TopBar {
if ui.button("Items").clicked() {
update_state
.edit_windows
- .add_window(luminol_ui::windows::items::Window::new(update_state.data));
+ .add_window(luminol_ui::windows::items::Window::new());
}
if ui.button("Common Events").clicked() {