diff --git a/crates/opensi-editor/src/app.rs b/crates/opensi-editor/src/app.rs index 170fb10..523555c 100644 --- a/crates/opensi-editor/src/app.rs +++ b/crates/opensi-editor/src/app.rs @@ -1,6 +1,9 @@ +use opensi_core::PackageNode; + use crate::{ file_dialogs::{self, LoadingPackageReceiver}, package_tree::{self}, + workarea, }; const FONT_REGULAR_ID: &'static str = "Regular"; @@ -63,7 +66,7 @@ impl eframe::App for EditorApp { ui.close_menu(); } if ui.button("💾 Сохранить").clicked() { - let PackageState::Active(ref package) = self.package_state else { + let PackageState::Active { ref package, .. } = self.package_state else { return; }; file_dialogs::export_dialog(package); @@ -77,7 +80,7 @@ impl eframe::App for EditorApp { } } }); - if let PackageState::Active(ref _package) = self.package_state { + if let PackageState::Active { .. } = self.package_state { ui.menu_button("Пак", |ui| { if ui.button("❌Закрыть").clicked() { self.package_state = PackageState::None; @@ -88,28 +91,31 @@ impl eframe::App for EditorApp { }); }); - egui::SidePanel::left("question-tree").min_width(200.0).show(ctx, |ui| { - match self.package_state { - PackageState::Active(ref mut package) => { - package_tree::package_tree(package, ui); - }, - _ => { - ui.weak("Пак не выбран"); - }, - } - }); + if let PackageState::Active { package, selected } = &mut self.package_state { + egui::SidePanel::left("question-tree").min_width(200.0).show(ctx, |ui| { + package_tree::package_tree(package, selected, ui); + }); + } - egui::CentralPanel::default().show(ctx, |ui| { - ui.with_layout( - egui::Layout::centered_and_justified(egui::Direction::LeftToRight), - |ui| { - let text = egui::RichText::new("We're so back") - .size(100.0) - .color(ui.style().visuals.weak_text_color()); - ui.add(egui::Label::new(text)); - }, - ); - }); + egui::CentralPanel::default() + .frame(egui::Frame::central_panel(&ctx.style()).inner_margin(16.0)) + .show(ctx, |ui| { + ui.with_layout( + egui::Layout::centered_and_justified(egui::Direction::LeftToRight), + |ui| { + if let PackageState::Active { package, selected } = &mut self.package_state + { + workarea::workarea(package, selected, ui); + } else { + let text = egui::RichText::new("OpenSI Editor") + .italics() + .size(64.0) + .color(ui.style().visuals.weak_text_color()); + ui.add(egui::Label::new(text).selectable(false)); + } + }, + ); + }); } } @@ -119,7 +125,10 @@ enum PackageState { None, #[serde(skip)] Loading(LoadingPackageReceiver), - Active(opensi_core::Package), + Active { + package: opensi_core::Package, + selected: Option, + }, } impl PackageState { @@ -128,7 +137,7 @@ impl PackageState { Self::Loading(receiver) => { match receiver.try_recv() { Ok(Ok(package)) => { - *self = Self::Active(package); + *self = Self::Active { package, selected: None }; }, Ok(Err(_err)) => { // TODO: error handle diff --git a/crates/opensi-editor/src/lib.rs b/crates/opensi-editor/src/lib.rs index 6a34c75..48f520b 100644 --- a/crates/opensi-editor/src/lib.rs +++ b/crates/opensi-editor/src/lib.rs @@ -2,6 +2,12 @@ mod app; mod file_dialogs; +mod package_tab; mod package_tree; +mod question_tab; +mod round_tab; +mod theme_tab; +mod utils; +mod workarea; pub use app::EditorApp; diff --git a/crates/opensi-editor/src/package_tab.rs b/crates/opensi-editor/src/package_tab.rs new file mode 100644 index 0000000..6c8ad4e --- /dev/null +++ b/crates/opensi-editor/src/package_tab.rs @@ -0,0 +1,8 @@ +use opensi_core::Package; + +pub fn package_tab(package: &mut Package, ui: &mut egui::Ui) { + ui.vertical(|ui| { + ui.label(&package.id); + ui.label(package.name.clone().unwrap_or_default()); + }); +} diff --git a/crates/opensi-editor/src/package_tree.rs b/crates/opensi-editor/src/package_tree.rs index edae074..92dedd3 100644 --- a/crates/opensi-editor/src/package_tree.rs +++ b/crates/opensi-editor/src/package_tree.rs @@ -1,28 +1,34 @@ -use std::borrow::Cow; - use egui::collapsing_header::CollapsingState; use opensi_core::{Package, PackageNode}; +use crate::utils::node_name; + /// Ui for a whole [`Package`] in a form of a tree. /// /// It can add new rounds, themes and questions, edit /// names/prices of existing ones and select them. -pub fn package_tree(package: &mut Package, ui: &mut egui::Ui) { +pub fn package_tree(package: &mut Package, selected: &mut Option, ui: &mut egui::Ui) { let name = package.name.as_ref().map(|name| name.as_str()).unwrap_or("Новый пакет вопросов"); ui.vertical_centered_justified(|ui| { - ui.heading(name); + let text = egui::RichText::new(name).strong().heading(); + if ui.add(egui::Label::new(text).sense(egui::Sense::click()).selectable(false)).clicked() { + *selected = None; + } }); ui.separator(); - egui::ScrollArea::vertical().show(ui, |ui| { - tree_node_ui(package, None, ui); - }); + egui::ScrollArea::vertical().show(ui, |ui| tree_node_ui(package, None, selected, ui)); } /// Recursive [`PackageNode`] ui. -fn tree_node_ui<'a>(package: &mut Package, node: Option, ui: &mut egui::Ui) { +fn tree_node_ui<'a>( + package: &mut Package, + node: Option, + selected: &mut Option, + ui: &mut egui::Ui, +) { fn plus_button(ui: &mut egui::Ui) -> bool { ui.vertical_centered_justified(|ui| ui.button("➕").clicked()).inner } @@ -145,7 +151,7 @@ fn tree_node_ui<'a>(package: &mut Package, node: Option, ui: &mut e ui.weak("Нет раундов"); } else { for index in 0..package.rounds.rounds.len() { - tree_node_ui(package, Some(PackageNode::Round { index }), ui); + tree_node_ui(package, Some(PackageNode::Round { index }), selected, ui); } } }); @@ -157,11 +163,11 @@ fn tree_node_ui<'a>(package: &mut Package, node: Option, ui: &mut e let id = egui::Id::new(node.index()).with(ui.id()); match node { - node @ PackageNode::Round { index } => { + PackageNode::Round { index } => { CollapsingState::load_with_default_open(ui.ctx(), id, true) .show_header(ui, |ui| { if node_button(package, node, ui) { - // TODO: selected round + *selected = Some(node); }; }) .body(|ui| { @@ -173,6 +179,7 @@ fn tree_node_ui<'a>(package: &mut Package, node: Option, ui: &mut e tree_node_ui( package, Some(PackageNode::Theme { round_index: index, index: theme_index }), + selected, ui, ); } @@ -181,11 +188,11 @@ fn tree_node_ui<'a>(package: &mut Package, node: Option, ui: &mut e } }); }, - node @ PackageNode::Theme { round_index, index } => { + PackageNode::Theme { round_index, index } => { CollapsingState::load_with_default_open(ui.ctx(), id, false) .show_header(ui, |ui| { if node_button(package, node, ui) { - // TODO: selected theme + *selected = Some(node); }; }) .body(|ui| { @@ -201,6 +208,7 @@ fn tree_node_ui<'a>(package: &mut Package, node: Option, ui: &mut e theme_index: index, index: question_index, }), + selected, ui, ); } @@ -211,28 +219,8 @@ fn tree_node_ui<'a>(package: &mut Package, node: Option, ui: &mut e }, PackageNode::Question { round_index, theme_index, index } => { if node_button(package, PackageNode::Question { round_index, theme_index, index }, ui) { - // TODO: selected question + *selected = Some(node); } }, } } - -/// Utility method to get a button name for a [`PackageNode`]. -fn node_name<'a>(node: PackageNode, package: &'a Package) -> Cow<'a, str> { - match node { - PackageNode::Round { index } => package - .get_round(index) - .map(|round| round.name.as_str()) - .unwrap_or("<Неизвестный раунд>") - .into(), - PackageNode::Theme { round_index, index } => package - .get_theme(round_index, index) - .map(|theme| theme.name.as_str()) - .unwrap_or("<Неизвестная тема>") - .into(), - PackageNode::Question { round_index, theme_index, index } => package - .get_question(round_index, theme_index, index) - .map(|question| format!("🗛 ({})", question.price).into()) - .unwrap_or("<Неизвестный вопрос>".into()), - } -} diff --git a/crates/opensi-editor/src/question_tab.rs b/crates/opensi-editor/src/question_tab.rs new file mode 100644 index 0000000..cfb527b --- /dev/null +++ b/crates/opensi-editor/src/question_tab.rs @@ -0,0 +1,7 @@ +use opensi_core::Question; + +use crate::utils::todo_label; + +pub fn question_tab(_question: &mut Question, ui: &mut egui::Ui) { + todo_label(ui); +} diff --git a/crates/opensi-editor/src/round_tab.rs b/crates/opensi-editor/src/round_tab.rs new file mode 100644 index 0000000..012a8bc --- /dev/null +++ b/crates/opensi-editor/src/round_tab.rs @@ -0,0 +1,7 @@ +use opensi_core::Round; + +use crate::utils::todo_label; + +pub fn round_tab(_round: &mut Round, ui: &mut egui::Ui) { + todo_label(ui); +} diff --git a/crates/opensi-editor/src/theme_tab.rs b/crates/opensi-editor/src/theme_tab.rs new file mode 100644 index 0000000..8074a12 --- /dev/null +++ b/crates/opensi-editor/src/theme_tab.rs @@ -0,0 +1,7 @@ +use opensi_core::Theme; + +use crate::utils::todo_label; + +pub fn theme_tab(_theme: &mut Theme, ui: &mut egui::Ui) { + todo_label(ui); +} diff --git a/crates/opensi-editor/src/utils.rs b/crates/opensi-editor/src/utils.rs new file mode 100644 index 0000000..b171456 --- /dev/null +++ b/crates/opensi-editor/src/utils.rs @@ -0,0 +1,36 @@ +use std::{borrow::Cow, fmt::Display}; + +use opensi_core::{Package, PackageNode}; + +/// A generic error label. +pub fn error_label(error: impl Display, ui: &mut egui::Ui) { + let text = egui::RichText::new(error.to_string()).color(egui::Color32::RED).size(24.0); + ui.add(egui::Label::new(text).selectable(true).wrap()); +} + +/// A stub todo label. +pub fn todo_label(ui: &mut egui::Ui) { + let text = + egui::RichText::new("TODO").background_color(egui::Color32::YELLOW).strong().size(24.0); + ui.add(egui::Label::new(text).selectable(false).extend()); +} + +/// Utility method to get a button name for a [`PackageNode`]. +pub fn node_name<'a>(node: PackageNode, package: &'a Package) -> Cow<'a, str> { + match node { + PackageNode::Round { index } => package + .get_round(index) + .map(|round| round.name.as_str()) + .unwrap_or("<Неизвестный раунд>") + .into(), + PackageNode::Theme { round_index, index } => package + .get_theme(round_index, index) + .map(|theme| theme.name.as_str()) + .unwrap_or("<Неизвестная тема>") + .into(), + PackageNode::Question { round_index, theme_index, index } => package + .get_question(round_index, theme_index, index) + .map(|question| format!("🗛 ({})", question.price).into()) + .unwrap_or("<Неизвестный вопрос>".into()), + } +} diff --git a/crates/opensi-editor/src/workarea.rs b/crates/opensi-editor/src/workarea.rs new file mode 100644 index 0000000..fc72d29 --- /dev/null +++ b/crates/opensi-editor/src/workarea.rs @@ -0,0 +1,123 @@ +use crate::{ + package_tab, question_tab, round_tab, theme_tab, + utils::{error_label, node_name}, +}; +use opensi_core::{Package, PackageNode}; + +/// UI for general area of [`Package`] editing. +pub fn workarea(package: &mut Package, selected: &mut Option, ui: &mut egui::Ui) { + ui.vertical(|ui| { + breadcrumbs(package, selected, ui); + + ui.add_space(16.0); + + ui.centered_and_justified(|ui| { + selected_tab(package, selected, ui); + }); + }); +} + +/// Tab ui based on what package node is selected. +fn selected_tab(package: &mut Package, selected: &mut Option, ui: &mut egui::Ui) { + match selected { + &mut Some(PackageNode::Round { index }) => { + if let Some(round) = package.get_round_mut(index) { + round_tab::round_tab(round, ui); + } else { + let error = format!("Невозможно найти раунд с индексом {index}"); + error_label(error, ui); + } + }, + &mut Some(PackageNode::Theme { round_index, index }) => { + if let Some(theme) = package.get_theme_mut(round_index, index) { + theme_tab::theme_tab(theme, ui); + } else { + let error = format!( + "Невозможно найти тему с индексом {index} (раунд с индексом {round_index})" + ); + error_label(error, ui); + } + }, + &mut Some(PackageNode::Question { round_index, theme_index, index }) => { + if let Some(question) = package.get_question_mut(round_index, theme_index, index) { + question_tab::question_tab(question, ui); + } else { + let error = [ + format!("Невозможно найти вопрос с индексом {index}"), + format!("(раунд с индексом {round_index}, тема с индексом {theme_index})"), + ] + .join(" "); + error_label(error, ui); + } + }, + None => { + package_tab::package_tab(package, ui); + }, + } +} + +/// Selection breadcrumbs ui. +fn breadcrumbs(package: &Package, selected: &mut Option, ui: &mut egui::Ui) { + fn breadcrumb(text: impl AsRef, ui: &mut egui::Ui) -> bool { + let text = egui::RichText::new(text.as_ref()).size(20.0); + let response = + ui.add(egui::Label::new(text).extend().sense(egui::Sense::click()).selectable(false)); + response.clicked() + } + + fn breadcrump_separator(ui: &mut egui::Ui) { + ui.add_space(8.0); + let text = egui::RichText::new("/").size(8.0).weak(); + ui.add(egui::Label::new(text).wrap().selectable(false)); + ui.add_space(8.0); + } + + fn root_breadcrumb(selected: &mut Option, ui: &mut egui::Ui) { + if breadcrumb("🏠", ui) { + *selected = None; + } + } + + fn node_breadcrumb( + node: PackageNode, + package: &Package, + selected: &mut Option, + ui: &mut egui::Ui, + ) { + let name = node_name(node, package); + if breadcrumb(name, ui) { + *selected = Some(node); + } + } + + ui.horizontal(|ui| { + root_breadcrumb(selected, ui); + + match *selected { + Some(node @ PackageNode::Round { .. }) => { + breadcrump_separator(ui); + node_breadcrumb(node, package, selected, ui); + }, + Some(node @ PackageNode::Theme { .. }) => { + breadcrump_separator(ui); + node_breadcrumb(node.get_parent().unwrap(), package, selected, ui); + breadcrump_separator(ui); + node_breadcrumb(node, package, selected, ui); + }, + Some(node @ PackageNode::Question { .. }) => { + breadcrump_separator(ui); + node_breadcrumb( + node.get_parent().unwrap().get_parent().unwrap(), + package, + selected, + ui, + ); + breadcrump_separator(ui); + node_breadcrumb(node.get_parent().unwrap(), package, selected, ui); + breadcrump_separator(ui); + node_breadcrumb(node, package, selected, ui); + }, + None => {}, + } + }); +}