diff --git a/crates/opensi-core/src/node.rs b/crates/opensi-core/src/node.rs index eec04d3..8e60590 100644 --- a/crates/opensi-core/src/node.rs +++ b/crates/opensi-core/src/node.rs @@ -28,8 +28,17 @@ impl PackageNode { pub fn parent(&self) -> Option { match self { PackageNode::Round(_) => None, - PackageNode::Theme(node) => Some(node.parent().into()), - PackageNode::Question(node) => Some(node.parent().into()), + PackageNode::Theme(idx) => Some(idx.parent().into()), + PackageNode::Question(idx) => Some(idx.parent().into()), + } + } + + /// Get child node of this node, unless it's a [`PackageNode::Question`]. + pub fn child(&self, child_idx: usize) -> Option { + match self { + PackageNode::Round(idx) => Some(idx.theme(child_idx).into()), + PackageNode::Theme(idx) => Some(idx.question(child_idx).into()), + PackageNode::Question(_) => None, } } diff --git a/crates/opensi-core/src/package.rs b/crates/opensi-core/src/package.rs index 971332c..6612c0a 100644 --- a/crates/opensi-core/src/package.rs +++ b/crates/opensi-core/src/package.rs @@ -154,6 +154,17 @@ impl Package { let round = Round { name: "Новый раунд".to_string(), ..Default::default() }; self.push_round(round) } + + /// Check if [`Round`] by index exist. + pub fn contains_round(&self, idx: impl Into) -> bool { + let idx = idx.into(); + *idx < self.rounds.len() + } + + /// Return amount of [`Round`]s in this package. + pub fn count_rounds(&self) -> usize { + self.rounds.len() + } } /// # Methods around [`Theme`] @@ -170,6 +181,12 @@ impl Package { self.get_round_mut(idx.round_index).and_then(|round| round.themes.get_mut(*idx)) } + /// Check if [`Theme`] by indices exist. + pub fn contains_theme(&self, idx: impl Into) -> bool { + let idx = idx.into(); + self.get_round(idx.parent()).map(|round| *idx < round.themes.len()).unwrap_or_default() + } + /// Remove [`Theme`] in [`Round`] by indices. pub fn remove_theme(&mut self, idx: impl Into) -> Option { let idx = idx.into(); @@ -215,6 +232,11 @@ impl Package { let theme = Theme { name: "Новая тема".to_string(), ..Default::default() }; self.push_theme(idx, theme) } + + /// Return amount of [`Theme`]s in a [`Round`]. + pub fn count_themes(&self, idx: impl Into) -> usize { + self.get_round(idx).map(|round| round.themes.len()).unwrap_or_default() + } } /// # Methods around [`Theme`]. @@ -231,6 +253,12 @@ impl Package { self.get_theme_mut(idx.parent()).and_then(|theme| theme.questions.get_mut(*idx)) } + /// Check if [`Question`] by indices exist. + pub fn contains_question(&self, idx: impl Into) -> bool { + let idx = idx.into(); + self.get_theme(idx.parent()).map(|theme| *idx < theme.questions.len()).unwrap_or_default() + } + /// Remove [`Question`] in [`Theme`] in [`Round`] by indices. pub fn remove_question(&mut self, idx: impl Into) -> Option { let idx = idx.into(); @@ -287,6 +315,11 @@ impl Package { let question = Question { price, ..Default::default() }; self.push_question(idx, question) } + + /// Return amount of [`Question`]s in a [`Theme`]. + pub fn count_questions(&self, idx: impl Into) -> usize { + self.get_theme(idx).map(|theme| theme.questions.len()).unwrap_or_default() + } } /// # IO and resource methods diff --git a/crates/opensi-editor/src/app/package_tab.rs b/crates/opensi-editor/src/app/package_tab.rs index a542125..31258dc 100644 --- a/crates/opensi-editor/src/app/package_tab.rs +++ b/crates/opensi-editor/src/app/package_tab.rs @@ -63,61 +63,15 @@ fn package_metadata_edit(package: &Package, ui: &mut egui::Ui) { fn package_rounds(package: &mut Package, selected: &mut Option, ui: &mut egui::Ui) { CardTable::new("package-rounds").show(ui, (1, package.rounds.len() + 1), |mut row| { - let index = row.index(); - let Some(round) = package.get_round(index) else { + let idx = row.index(); + if package.contains_round(idx) { + if row.round(package, idx, CardStyle::Important).clicked() { + *selected = Some(idx.into()); + } + } else { if row.custom("➕ Новый раунд", CardStyle::Weak).clicked() { package.allocate_round(); } - return; - }; - - if row.round(round, CardStyle::Important).clicked() { - *selected = Some(index.into()); } }); - // let button_size = 20.0; - // egui_extras::TableBuilder::new(ui) - // .id_salt("rounds") - // .column(egui_extras::Column::remainder()) - // .column(egui_extras::Column::exact(button_size)) - // .cell_layout( - // egui::Layout::top_down_justified(egui::Align::Center) - // .with_main_wrap(false) - // .with_cross_justify(true) - // .with_cross_align(egui::Align::Center), - // ) - // .body(|mut body| { - // for index in 0..package.rounds.len() { - // body.row((button_size + 4.0) * 3.0, |mut row| { - // row.col(|ui| { - // let Some(round) = package.get_round_mut(index) else { - // return; - // }; - // if ui.add(Card::Round(round)).clicked() { - // *selected = Some(index.into()); - // } - // }); - // row.col(|ui| { - // ui.add_space(4.0); - // if ui.button("✏").on_hover_text("Редактировать").clicked() - // { - // *selected = Some(index.into()); - // } - // if ui.button("🗐").on_hover_text("Дублировать").clicked() - // { - // package.duplicate_round(index); - // } - // if danger_button("❌", ui).on_hover_text("Удалить").clicked() - // { - // package.remove_round(index); - // } - // }); - // }); - // } - // }); - // }); - - // strip.cell(|ui| { - // }); - // }); } diff --git a/crates/opensi-editor/src/app/package_tree.rs b/crates/opensi-editor/src/app/package_tree.rs index 3e7dacd..895f1db 100644 --- a/crates/opensi-editor/src/app/package_tree.rs +++ b/crates/opensi-editor/src/app/package_tree.rs @@ -1,7 +1,7 @@ use egui::collapsing_header::CollapsingState; use opensi_core::prelude::*; -use crate::element::node_name; +use crate::element::{node_context::PackageNodeContextMenu, node_name}; /// Ui for a whole [`Package`] in a form of a tree. /// @@ -35,133 +35,15 @@ fn tree_node_ui<'a>( is_selected: bool, ui: &mut egui::Ui, ) -> bool { - #[derive(Default)] - struct Result { - new_name: Option, - is_selected: bool, - is_duplicated: bool, - is_populated: bool, - is_deleted: bool, - } - let id = match node { - PackageNode::Round(RoundIdx { index }) => format!("tree-node-round-{index}"), - PackageNode::Theme(ThemeIdx { round_index, index }) => { - format!("tree-node-theme-{round_index}-{index}") - }, - PackageNode::Question(QuestionIdx { round_index, theme_index, index }) => { - format!("tree-node-question-{round_index}-{theme_index}-{index}") - }, - }; - let id = egui::Id::new(id); - let mut result = Result::default(); - let is_question = matches!(node, PackageNode::Question { .. }); - - if let Some(mut new_name) = ui.memory(|memory| memory.data.get_temp::(id)) { - // renaming in process - let response = ui.text_edit_singleline(&mut new_name); - - let is_renaming_done = ui.input(|input| input.key_pressed(egui::Key::Enter)); - let is_exiting = is_renaming_done || !response.has_focus(); - - if is_renaming_done { - result.new_name = Some(new_name); - } else if response.changed() { - if is_question { - new_name.retain(|c| c.is_digit(10)); - } - ui.memory_mut(|memory| memory.data.insert_temp(id, new_name)); - } - - if is_exiting { - ui.memory_mut(|memory| memory.data.remove_temp::(id)); - ui.ctx().request_repaint(); - } - } else { - // regular button - let node_name = node_name(node, package); - let button = egui::Button::new(node_name.as_ref()) - .selected(is_selected) - .fill(egui::Color32::TRANSPARENT); - let response = ui.add(button); - - response.context_menu(|ui| { - if let Some(add_text) = match node { - PackageNode::Round { .. } => Some("Добавить тему"), - PackageNode::Theme { .. } => Some("Добавить вопрос"), - PackageNode::Question { .. } => None, - } { - if ui.button(format!("➕ {add_text}")).clicked() { - result.is_populated = true; - ui.close_menu(); - } - ui.separator(); - } - - let change_text = if is_question { - "Изменить цену" - } else { - "Переименовать" - }; - if ui.button(format!("✏ {}", change_text)).clicked() { - ui.memory_mut(|memory| { - let mut renaming = node_name.to_string(); - if is_question { - renaming.retain(|c| c.is_digit(10)); - } - memory.data.insert_temp(id, renaming); - }); - response.request_focus(); - ui.close_menu(); - } - if ui.button("🗐 Дублировать").clicked() { - result.is_duplicated = true; - ui.close_menu(); - } - ui.separator(); - if ui.button("❌ Удалить").clicked() { - result.is_deleted = true; - ui.close_menu(); - } - }); - if response.clicked() { - result.is_selected = true; - } - } + let node_name = node_name(node, package); + let button = egui::Button::new(node_name.as_ref()) + .selected(is_selected) + .fill(egui::Color32::TRANSPARENT); + let response = ui.add(button); - if result.is_populated { - if let Some(parent) = node.parent() { - package.allocate_node(parent); - } - } - if result.is_duplicated { - package.duplicate_node(node); - } - if result.is_deleted { - package.remove_node(node); - } - if let Some(new_name) = result.new_name { - match node { - PackageNode::Round(idx) => { - if let Some(round) = package.get_round_mut(idx) { - round.name = new_name; - }; - }, - PackageNode::Theme(idx) => { - if let Some(theme) = package.get_theme_mut(idx) { - theme.name = new_name; - }; - }, - PackageNode::Question(idx) => { - if let Some(question) = package.get_question_mut(idx) { - if let Ok(new_price) = new_name.parse() { - question.price = new_price; - } - }; - }, - } - } + PackageNodeContextMenu { package, node }.show(&response, ui); - return result.is_selected; + return response.clicked(); } let Some(node) = node else { diff --git a/crates/opensi-editor/src/app/round_tab.rs b/crates/opensi-editor/src/app/round_tab.rs index 2ff76db..1d656da 100644 --- a/crates/opensi-editor/src/app/round_tab.rs +++ b/crates/opensi-editor/src/app/round_tab.rs @@ -72,26 +72,27 @@ fn round_themes( }; CardTable::new("round-themes").show(ui, count, |mut row| { - let theme_idx = idx.theme(row.index()); - let Some(theme) = package.get_theme(theme_idx) else { - if row.custom("➕ Новая тема", CardStyle::Weak).clicked() { - package.allocate_theme(idx); - } - return; - }; + let idx = idx.theme(row.index()); - if row.theme(theme, CardStyle::Important).clicked() { - *selected = Some(theme_idx.into()); - } + if package.contains_theme(idx) { + if row.theme(package, idx, CardStyle::Important).clicked() { + *selected = Some(idx.into()); + } - for (question_index, question) in theme.questions.iter().enumerate() { - if row.question(question, CardStyle::Normal).clicked() { - *selected = Some(theme_idx.question(question_index).into()); + for question_idx in 0..package.count_questions(idx).min(count.0 - 2) { + let idx = idx.question(question_idx); + if row.question(package, idx, CardStyle::Normal).clicked() { + *selected = Some(idx.into()); + } } - } - if row.custom("➕ Новый вопрос", CardStyle::Weak).clicked() { - package.allocate_question(theme_idx); + if row.custom("➕ Новый вопрос", CardStyle::Weak).clicked() { + package.allocate_question(idx); + } + } else { + if row.custom("➕ Новая тема", CardStyle::Weak).clicked() { + package.allocate_theme(idx.parent()); + } } }); } diff --git a/crates/opensi-editor/src/app/theme_tab.rs b/crates/opensi-editor/src/app/theme_tab.rs index 66f4acf..5cc3b1a 100644 --- a/crates/opensi-editor/src/app/theme_tab.rs +++ b/crates/opensi-editor/src/app/theme_tab.rs @@ -63,16 +63,15 @@ fn theme_questions( }; CardTable::new("theme-questions").show(ui, (1, theme.questions.len() + 1), |mut row| { - let index = row.index(); - let Some(question) = package.get_question(idx.question(index)) else { + let idx = idx.question(row.index()); + if package.contains_question(idx) { + if row.question(package, idx, CardStyle::Important).clicked() { + *selected = Some(idx.into()); + } + } else { if row.custom("➕ Новый вопрос", CardStyle::Weak).clicked() { - package.allocate_question(idx); + package.allocate_question(idx.parent()); } - return; - }; - - if row.question(question, CardStyle::Normal).clicked() { - *selected = Some(idx.question(index).into()); } }); } diff --git a/crates/opensi-editor/src/element/card.rs b/crates/opensi-editor/src/element/card.rs index 6f5d69a..2ec2fd3 100644 --- a/crates/opensi-editor/src/element/card.rs +++ b/crates/opensi-editor/src/element/card.rs @@ -1,22 +1,24 @@ use opensi_core::prelude::*; use std::borrow::Cow; -use super::{question_name, round_name, theme_name, unselectable_label}; +use super::{ + node_context::PackageNodeContextMenu, question_name, round_name, theme_name, unselectable_label, +}; /// Rectangular cilckable card for package nodes (and more). // TODO: context menu -#[derive(Debug, Clone, Copy)] +#[derive(Debug)] pub struct Card<'a> { kind: CardKind<'a>, style: CardStyle, } /// Types of content of [`Card`]. -#[derive(Debug, Clone, Copy)] +#[derive(Debug)] pub enum CardKind<'a> { - Round(&'a Round), - Theme(&'a Theme), - Question(&'a Question), + Round(&'a mut Package, RoundIdx), + Theme(&'a mut Package, ThemeIdx), + Question(&'a mut Package, QuestionIdx), Custom(&'a str), } @@ -61,13 +63,75 @@ impl CardStyle { } } +impl Card<'_> { + pub fn content(&self, ui: &mut egui::Ui) { + let text_color = self.style.text_color(ui.visuals()); + + let text = match &self.kind { + CardKind::Round(package, idx) => { + let Some(round) = package.get_round(*idx) else { + return; + }; + ui.vertical_centered_justified(|ui| { + unselectable_label( + egui::RichText::new(round_name(round)).size(22.0).color(text_color), + ui, + ); + ui.separator(); + if round.themes.is_empty() { + unselectable_label("Пусто", ui); + } else { + ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| { + for theme in &round.themes { + unselectable_label(format!("⚫ {}", theme_name(theme)), ui); + } + }); + } + }); + return; + }, + CardKind::Theme(package, idx) => { + let Some(theme) = package.get_theme(*idx) else { + return; + }; + theme_name(theme).into() + }, + CardKind::Question(package, idx) => { + let Some(question) = package.get_question(*idx) else { + return; + }; + question_name(question).into() + }, + &CardKind::Custom(str) => Cow::Borrowed(str), + }; + + // TODO: aprox, accurate values + let font_size = 22.0 - (text.len() as isize - 8).max(0) as f32 * 0.3; + let label = + egui::Label::new(egui::RichText::new(text.as_ref()).size(font_size).color(text_color)) + .selectable(false) + .halign(egui::Align::Center) + .wrap(); + + ui.add(label); + } + + fn context_menu(&mut self, response: &egui::Response, ui: &mut egui::Ui) { + let (package, node) = match &mut self.kind { + CardKind::Round(package, round_idx) => (package, (*round_idx).into()), + CardKind::Theme(package, theme_idx) => (package, (*theme_idx).into()), + CardKind::Question(package, question_idx) => (package, (*question_idx).into()), + _ => return, + }; + + PackageNodeContextMenu { package, node }.show(response, ui); + } +} + impl<'a> egui::Widget for Card<'a> { - fn ui(self, ui: &mut egui::Ui) -> egui::Response { - let (fill_color, text_color, stroke) = ( - self.style.fill_color(ui.visuals()), - self.style.text_color(ui.visuals()), - self.style.stroke(ui.visuals()), - ); + fn ui(mut self, ui: &mut egui::Ui) -> egui::Response { + let (fill_color, stroke) = + (self.style.fill_color(ui.visuals()), self.style.stroke(ui.visuals())); let mut frame = egui::Frame::default() .inner_margin(16.0) .outer_margin(egui::Margin::symmetric(0.0, 4.0)) @@ -81,45 +145,7 @@ impl<'a> egui::Widget for Card<'a> { frame.content_ui.allocate_ui(egui::vec2(card_width, card_height), |ui| { ui.set_min_size(egui::vec2(card_width, card_height)); - - let text = match self.kind { - CardKind::Round(round) => { - ui.vertical_centered_justified(|ui| { - unselectable_label( - egui::RichText::new(round_name(round)).size(22.0).color(text_color), - ui, - ); - ui.separator(); - if round.themes.is_empty() { - unselectable_label("Пусто", ui); - } else { - ui.with_layout( - egui::Layout::top_down_justified(egui::Align::Min), - |ui| { - for theme in &round.themes { - unselectable_label(format!("⚫ {}", theme_name(theme)), ui); - } - }, - ); - } - }); - return; - }, - CardKind::Theme(theme) => theme_name(theme).into(), - CardKind::Question(question) => question_name(question).into(), - CardKind::Custom(str) => Cow::Borrowed(str), - }; - - // TODO: aprox, accurate values - let font_size = 22.0 - (text.len() as isize - 8).max(0) as f32 * 0.3; - let label = egui::Label::new( - egui::RichText::new(text.as_ref()).size(font_size).color(text_color), - ) - .selectable(false) - .halign(egui::Align::Center) - .wrap(); - - ui.add(label); + self.content(ui); }); let rect = frame.content_ui.min_rect() + frame.frame.inner_margin + frame.frame.outer_margin; @@ -129,6 +155,7 @@ impl<'a> egui::Widget for Card<'a> { frame.frame.fill = self.style.hover_fill_color(ui.visuals()); } frame.paint(ui); + self.context_menu(&response, ui); response } } @@ -151,16 +178,34 @@ impl CardTableRow<'_, '_> { unsafe { response.assume_init() } } - pub fn round(&mut self, round: &Round, style: CardStyle) -> egui::Response { - self.row(|ui| ui.add(Card { kind: CardKind::Round(round), style })) + pub fn round( + &mut self, + package: &mut Package, + idx: impl Into, + style: CardStyle, + ) -> egui::Response { + let idx = idx.into(); + self.row(|ui| ui.add(Card { kind: CardKind::Round(package, idx), style })) } - pub fn theme(&mut self, theme: &Theme, style: CardStyle) -> egui::Response { - self.row(|ui| ui.add(Card { kind: CardKind::Theme(theme), style })) + pub fn theme( + &mut self, + package: &mut Package, + idx: impl Into, + style: CardStyle, + ) -> egui::Response { + let idx = idx.into(); + self.row(|ui| ui.add(Card { kind: CardKind::Theme(package, idx), style })) } - pub fn question(&mut self, question: &Question, style: CardStyle) -> egui::Response { - self.row(|ui| ui.add(Card { kind: CardKind::Question(question), style })) + pub fn question( + &mut self, + package: &mut Package, + idx: impl Into, + style: CardStyle, + ) -> egui::Response { + let idx = idx.into(); + self.row(|ui| ui.add(Card { kind: CardKind::Question(package, idx), style })) } pub fn custom(&mut self, str: impl AsRef, style: CardStyle) -> egui::Response { diff --git a/crates/opensi-editor/src/element/mod.rs b/crates/opensi-editor/src/element/mod.rs index 1c0d7ec..4d4acf6 100644 --- a/crates/opensi-editor/src/element/mod.rs +++ b/crates/opensi-editor/src/element/mod.rs @@ -1,6 +1,7 @@ pub mod card; pub mod common; pub mod naming; +pub mod node_context; pub mod property; pub use common::*; diff --git a/crates/opensi-editor/src/element/node_context.rs b/crates/opensi-editor/src/element/node_context.rs new file mode 100644 index 0000000..4eb81cf --- /dev/null +++ b/crates/opensi-editor/src/element/node_context.rs @@ -0,0 +1,148 @@ +use opensi_core::prelude::*; + +use super::danger_button; + +/// Context menu for [`PackageNode`]. +pub struct PackageNodeContextMenu<'p> { + pub package: &'p mut Package, + pub node: PackageNode, +} + +impl PackageNodeContextMenu<'_> { + pub fn show(self, source: &egui::Response, ui: &mut egui::Ui) { + let is_question = matches!(self.node, PackageNode::Question(..)); + let change_text = + if is_question { "Изменить цену" } else { "Переименовать" }; + let new_value_id = source.id.with(egui::Id::new("new-value")); + + let modal = + egui_modal::Modal::new(ui.ctx(), format!("{}", source.id.with("modal").value())) + .with_close_on_outside_click(true); + + modal.show(|ui| { + let mut is_renaming_done = false; + + modal.title(ui, change_text); + modal.frame(ui, |ui| { + egui::Grid::new(source.id.with("modal-grid")).num_columns(2).show(ui, |ui| { + modal.icon( + ui, + egui_modal::Icon::Custom(( + "✏".to_string(), + ui.visuals().strong_text_color(), + )), + ); + ui.vertical(|ui| { + let body = match self.node { + PackageNode::Round(_) => "Введите новое название для раунда:", + PackageNode::Theme(_) => "Введите новое название для темы:", + PackageNode::Question(_) => "Введите новую цену для вопроса:", + }; + ui.label(body); + + let mut new_value = ui + .memory(|memory| memory.data.get_temp::(new_value_id)) + .unwrap_or_default(); + let response = ui.add( + egui::TextEdit::singleline(&mut new_value) + .id_salt(source.id.with("edit")), + ); + response.request_focus(); + + if response.changed() { + if is_question { + new_value.retain(|c| c.is_digit(10)); + } + ui.memory_mut(|memory| { + memory.data.insert_temp(new_value_id, new_value) + }); + } + + is_renaming_done = ui.input(|input| input.key_pressed(egui::Key::Enter)); + }); + }); + }); + modal.buttons(ui, |ui| { + if modal.button(ui, "Отмена").clicked() { + is_renaming_done = false; + }; + if modal.suggested_button(ui, "Подтвердить").clicked() { + is_renaming_done = true; + } + }); + + if is_renaming_done { + modal.close(); + let new_value = ui + .memory(|memory| memory.data.get_temp::(new_value_id)) + .unwrap_or_default(); + + match self.node { + PackageNode::Round(idx) => { + if let Some(round) = self.package.get_round_mut(idx) { + round.name = new_value; + }; + }, + PackageNode::Theme(idx) => { + if let Some(theme) = self.package.get_theme_mut(idx) { + theme.name = new_value; + }; + }, + PackageNode::Question(idx) => { + if let Some(question) = self.package.get_question_mut(idx) { + if let Ok(new_price) = new_value.parse() { + question.price = new_price; + } + }; + }, + } + } + }); + + source.context_menu(|ui| { + if let Some(add_text) = match self.node { + PackageNode::Round { .. } => Some("Добавить тему"), + PackageNode::Theme { .. } => Some("Добавить вопрос"), + PackageNode::Question { .. } => None, + } { + if ui.button(format!("➕ {add_text}")).clicked() { + self.package.allocate_node(self.node.child(0).unwrap()); + ui.close_menu(); + } + ui.separator(); + } + + if ui.button(format!("✏ {}", change_text)).clicked() { + let value = match self.node { + PackageNode::Round(idx) => self + .package + .get_round(idx) + .map(|round| round.name.clone()) + .unwrap_or_default(), + PackageNode::Theme(idx) => self + .package + .get_theme(idx) + .map(|theme| theme.name.clone()) + .unwrap_or_default(), + PackageNode::Question(idx) => self + .package + .get_question(idx) + .map(|question| question.price.to_string()) + .unwrap_or_default(), + }; + ui.memory_mut(|memory| memory.data.insert_temp(new_value_id, value)); + ui.close_menu(); + modal.open(); + } + if ui.button("🗐 Дублировать").clicked() { + self.package.duplicate_node(self.node); + ui.close_menu(); + } + ui.separator(); + if danger_button("❌ Удалить", ui).clicked() { + self.package.remove_node(self.node); + ui.close_menu(); + } + }); + } +}