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!();
+ }
+ });
});
});