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() {