From c34cd730c5703d39cbff82ec7664b7d57aa4dfd6 Mon Sep 17 00:00:00 2001 From: Mikolaj Wielgus Date: Tue, 9 Jul 2024 02:41:20 +0200 Subject: [PATCH] egui: add keyboard shortcuts and basic action abstraction --- src/bin/topola-egui/action.rs | 81 ++++++++++++ src/bin/topola-egui/main.rs | 1 + src/bin/topola-egui/top.rs | 233 ++++++++++++++++++---------------- 3 files changed, 209 insertions(+), 106 deletions(-) create mode 100644 src/bin/topola-egui/action.rs diff --git a/src/bin/topola-egui/action.rs b/src/bin/topola-egui/action.rs new file mode 100644 index 0000000..35a9680 --- /dev/null +++ b/src/bin/topola-egui/action.rs @@ -0,0 +1,81 @@ +pub struct Action { + name: String, + shortcut: egui::KeyboardShortcut, +} + +impl Action { + pub fn new(name: &str, modifiers: egui::Modifiers, key: egui::Key) -> Self { + Self { + name: String::from(name), + shortcut: egui::KeyboardShortcut::new(modifiers, key), + } + } + + fn widget_text(&self) -> String { + format!( + "{} ({})", + self.name, + self.shortcut.format(&egui::ModifierNames::NAMES, false) + ) + } +} + +pub struct Trigger { + action: Action, + triggered: bool, +} + +impl Trigger { + pub fn new(action: Action) -> Self { + Self { + action, + triggered: false, + } + } + + pub fn button(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + self.triggered = ui.button(self.action.widget_text()).clicked(); + } + + pub fn consume_key_triggered(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) -> bool { + self.consume_key(ctx, ui); + self.triggered() + } + + fn consume_key(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + if ctx.input_mut(|i| i.consume_shortcut(&self.action.shortcut)) { + self.triggered = true; + } + } + + fn triggered(&self) -> bool { + self.triggered + } +} + +pub struct Switch { + action: Action, +} + +impl Switch { + pub fn new(action: Action) -> Self { + Self { action } + } + + pub fn toggle_widget(&mut self, ctx: &egui::Context, ui: &mut egui::Ui, selected: &mut bool) { + ui.toggle_value(selected, self.action.widget_text()); + } + + pub fn consume_key_enabled( + &mut self, + ctx: &egui::Context, + ui: &mut egui::Ui, + selected: &mut bool, + ) -> bool { + if ctx.input_mut(|i| i.consume_shortcut(&self.action.shortcut)) { + *selected = !*selected; + } + + *selected + } +} diff --git a/src/bin/topola-egui/main.rs b/src/bin/topola-egui/main.rs index 735bf16..f8a2bec 100644 --- a/src/bin/topola-egui/main.rs +++ b/src/bin/topola-egui/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release +mod action; mod app; mod bottom; mod file_receiver; diff --git a/src/bin/topola-egui/top.rs b/src/bin/topola-egui/top.rs index 65ec1b2..fc12d57 100644 --- a/src/bin/topola-egui/top.rs +++ b/src/bin/topola-egui/top.rs @@ -11,6 +11,7 @@ use topola::{ }; use crate::{ + action::{Action, Switch, Trigger}, app::{channel_text, execute}, file_sender::FileSender, overlay::Overlay, @@ -40,123 +41,143 @@ impl Top { maybe_execute: &mut Option, maybe_overlay: &mut Option, ) -> Result<(), InvokerError> { - Ok::<(), InvokerError>( - egui::TopBottomPanel::top("top_panel") - .show(ctx, |ui| { - egui::menu::bar(ui, |ui| { - ui.menu_button("File", |ui| { - if ui.button("Open").clicked() { - // `Context` is cheap to clone as it's wrapped in an `Arc`. - let ctx = ui.ctx().clone(); - // NOTE: On Linux, this requires Zenity to be installed on your system. - let task = rfd::AsyncFileDialog::new().pick_file(); + let mut open_design = + Trigger::new(Action::new("Open", egui::Modifiers::CTRL, egui::Key::O)); + let mut import_history = Trigger::new(Action::new( + "Import history", + egui::Modifiers::CTRL, + egui::Key::I, + )); + let mut export_history = Trigger::new(Action::new( + "Export history", + egui::Modifiers::CTRL, + egui::Key::E, + )); + let mut quit = Trigger::new(Action::new("Quit", egui::Modifiers::CTRL, egui::Key::V)); + let mut autoroute = Trigger::new(Action::new( + "Autoroute", + egui::Modifiers::CTRL, + egui::Key::A, + )); + let mut place_via = Switch::new(Action::new( + "Place Via", + egui::Modifiers::CTRL, + egui::Key::P, + )); + let mut undo = Trigger::new(Action::new("Undo", egui::Modifiers::CTRL, egui::Key::Z)); + let mut redo = Trigger::new(Action::new("Redo", egui::Modifiers::CTRL, egui::Key::Y)); - execute(async move { - if let Some(file_handle) = task.await { - let file_sender = FileSender::new(content_sender); - file_sender.send(file_handle).await; - ctx.request_repaint(); - } - }); - } + egui::TopBottomPanel::top("top_panel") + .show(ctx, |ui| { + egui::menu::bar(ui, |ui| { + ui.menu_button("File", |ui| { + open_design.button(ctx, ui); - ui.separator(); + ui.separator(); - if ui.button("Load history").clicked() { - let ctx = ui.ctx().clone(); - let task = rfd::AsyncFileDialog::new().pick_file(); + import_history.button(ctx, ui); + export_history.button(ctx, ui); - execute(async move { - if let Some(file_handle) = task.await { - let file_sender = FileSender::new(history_sender); - file_sender.send(file_handle).await; - ctx.request_repaint(); - } - }); - } else if ui.button("Save history").clicked() { - if let Some(invoker) = - arc_mutex_maybe_invoker.clone().lock().unwrap().as_ref() - { - let ctx = ui.ctx().clone(); - let task = rfd::AsyncFileDialog::new().save_file(); + ui.separator(); - // FIXME: I don't think we should be buffering everything in a `Vec`. - let mut writebuf = vec![]; - serde_json::to_writer_pretty(&mut writebuf, invoker.history()); + // "Quit" button wouldn't work on a Web page. + if !cfg!(target_arch = "wasm32") { + quit.button(ctx, ui); + } + }); - execute(async move { - if let Some(file_handle) = task.await { - dbg!(file_handle.write(&writebuf).await); - ctx.request_repaint(); - } - }); - } - } + ui.separator(); - ui.separator(); + autoroute.button(ctx, ui); - // "Quit" button wouldn't work on a Web page. - if !cfg!(target_arch = "wasm32") { - if ui.button("Quit").clicked() { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } + place_via.toggle_widget(ctx, ui, &mut self.is_placing_via); + + ui.separator(); + + undo.button(ctx, ui); + redo.button(ctx, ui); + + ui.separator(); + + ui.toggle_value(&mut self.show_ratsnest, "Show Ratsnest"); + ui.toggle_value(&mut self.show_navmesh, "Show Navmesh"); + + ui.separator(); + + egui::widgets::global_dark_light_mode_buttons(ui); + }); + + if open_design.consume_key_triggered(ctx, ui) { + // NOTE: On Linux, this requires Zenity to be installed on your system. + let ctx = ctx.clone(); + let task = rfd::AsyncFileDialog::new().pick_file(); + + execute(async move { + if let Some(file_handle) = task.await { + let file_sender = FileSender::new(content_sender); + file_sender.send(file_handle).await; + ctx.request_repaint(); + } + }); + } else if import_history.consume_key_triggered(ctx, ui) { + let ctx = ctx.clone(); + let task = rfd::AsyncFileDialog::new().pick_file(); + + execute(async move { + if let Some(file_handle) = task.await { + let file_sender = FileSender::new(history_sender); + file_sender.send(file_handle).await; + ctx.request_repaint(); + } + }); + } else if export_history.consume_key_triggered(ctx, ui) { + if let Some(invoker) = arc_mutex_maybe_invoker.clone().lock().unwrap().as_ref() + { + let ctx = ctx.clone(); + let task = rfd::AsyncFileDialog::new().save_file(); + + // FIXME: I don't think we should be buffering everything in a `Vec`. + let mut writebuf = vec![]; + serde_json::to_writer_pretty(&mut writebuf, invoker.history()); + + execute(async move { + if let Some(file_handle) = task.await { + file_handle.write(&writebuf).await; + ctx.request_repaint(); } }); - - ui.separator(); - - if ui.button("Autoroute").clicked() { - if maybe_execute.as_mut().map_or(true, |execute| { - matches!(execute.maybe_status(), Some(InvokerStatus::Finished)) - }) { - if let (Some(invoker), Some(ref mut overlay)) = ( - arc_mutex_maybe_invoker.lock().unwrap().as_mut(), - maybe_overlay, - ) { - let selection = overlay.selection().clone(); - overlay.clear_selection(); - maybe_execute.insert(ExecuteWithStatus::new( - invoker.execute_walk(Command::Autoroute(selection))?, - )); - } - } + } + } else if quit.consume_key_triggered(ctx, ui) { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else if autoroute.consume_key_triggered(ctx, ui) { + if maybe_execute.as_mut().map_or(true, |execute| { + matches!(execute.maybe_status(), Some(InvokerStatus::Finished)) + }) { + if let (Some(invoker), Some(ref mut overlay)) = ( + arc_mutex_maybe_invoker.lock().unwrap().as_mut(), + maybe_overlay, + ) { + let selection = overlay.selection().clone(); + overlay.clear_selection(); + maybe_execute.insert(ExecuteWithStatus::new( + invoker.execute_walk(Command::Autoroute(selection))?, + )); } + } + } else if place_via.consume_key_enabled(ctx, ui, &mut self.is_placing_via) { + } else if undo.consume_key_triggered(ctx, ui) { + if let Some(invoker) = arc_mutex_maybe_invoker.lock().unwrap().as_mut() { + invoker.undo(); + } + } else if redo.consume_key_triggered(ctx, ui) { + if let Some(ref mut invoker) = arc_mutex_maybe_invoker.lock().unwrap().as_mut() + { + invoker.redo(); + } + } - ui.toggle_value(&mut self.is_placing_via, "Place Via"); - - ui.separator(); - - if ui.button("Undo").clicked() - || ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Z)) - { - if let Some(invoker) = arc_mutex_maybe_invoker.lock().unwrap().as_mut() - { - invoker.undo(); - } - } - - if ui.button("Redo").clicked() - || ctx.input_mut(|i| i.consume_key(egui::Modifiers::CTRL, egui::Key::Y)) - { - if let Some(ref mut invoker) = - arc_mutex_maybe_invoker.lock().unwrap().as_mut() - { - invoker.redo(); - } - } - - ui.separator(); - - ui.toggle_value(&mut self.show_ratsnest, "Show Ratsnest"); - ui.toggle_value(&mut self.show_navmesh, "Show Navmesh"); - - ui.separator(); - - egui::widgets::global_dark_light_mode_buttons(ui); - Ok::<(), InvokerError>(()) - }); - }) - .inner, - ) + Ok::<(), InvokerError>(()) + }) + .inner } }