From 2d1eb5b4b1d29ca857938610f91acb5807b4c6b6 Mon Sep 17 00:00:00 2001 From: Federico Lolli <federico.lolli@skywarder.eu> Date: Tue, 25 Feb 2025 15:47:12 +0000 Subject: [PATCH] Implemented basic status Bar with a widget showing reception frequency on the left and control buttons on the right (open sources, layout manager and theme switch). --- src/mavlink/message_broker.rs | 63 ++- src/ui.rs | 1 + src/ui/composable_view.rs | 63 ++- src/ui/composable_view_BACKUP_21160.rs | 545 +++++++++++++++++++++++++ src/ui/composable_view_BASE_21160.rs | 318 +++++++++++++++ src/ui/composable_view_LOCAL_21160.rs | 511 +++++++++++++++++++++++ src/ui/composable_view_REMOTE_21160.rs | 336 +++++++++++++++ src/ui/widgets.rs | 1 + src/ui/widgets/reception_led.rs | 58 +++ 9 files changed, 1874 insertions(+), 22 deletions(-) create mode 100644 src/ui/composable_view_BACKUP_21160.rs create mode 100644 src/ui/composable_view_BASE_21160.rs create mode 100644 src/ui/composable_view_LOCAL_21160.rs create mode 100644 src/ui/composable_view_REMOTE_21160.rs create mode 100644 src/ui/widgets.rs create mode 100644 src/ui/widgets/reception_led.rs diff --git a/src/mavlink/message_broker.rs b/src/mavlink/message_broker.rs index cb7e90c..a4b070a 100644 --- a/src/mavlink/message_broker.rs +++ b/src/mavlink/message_broker.rs @@ -13,9 +13,11 @@ use std::{ atomic::{AtomicBool, Ordering}, Arc, }, + time::{Duration, Instant}, }; use anyhow::{Context, Result}; +use parking_lot::Mutex; use ring_channel::{ring_channel, RingReceiver, RingSender}; use serde::{Deserialize, Serialize}; use tokio::{net::UdpSocket, task::JoinHandle}; @@ -63,6 +65,8 @@ pub struct MessageBroker { /// map(widget ID -> queue of messages left for update) update_queues: HashMap<ViewId, (u32, VecDeque<TimedMessage>)>, // == Internal == + /// instant queue used for frequency calculation and reception time + last_receptions: Arc<Mutex<ReceptionQueue>>, /// Flag to stop the listener running_flag: Arc<AtomicBool>, /// Listener message sender @@ -82,6 +86,8 @@ impl MessageBroker { Self { messages: HashMap::new(), update_queues: HashMap::new(), + // TODO: make this configurable + last_receptions: Arc::new(Mutex::new(ReceptionQueue::new(Duration::from_secs(1)))), tx, rx, ctx, @@ -94,7 +100,7 @@ impl MessageBroker { /// validity based on `is_valid` method of the view. pub fn refresh_view<V: MessageView>(&mut self, view: &mut V) -> MavlinkResult<()> { self.process_incoming_msgs(); - if !view.is_valid() || !self.is_view_subscribed(view.view_id()) { + if !view.is_valid() || !self.is_view_subscribed(&view.view_id()) { self.init_view(view)?; } else { self.update_view(view)?; @@ -117,6 +123,7 @@ impl MessageBroker { // Stop the current listener if it exists self.stop_listening(); self.running_flag.store(true, Ordering::Relaxed); + let last_receptions = Arc::clone(&self.last_receptions); let tx = self.tx.clone(); let ctx = self.ctx.clone(); @@ -141,6 +148,7 @@ impl MessageBroker { debug!("Received message: {:?}", mav_message); tx.send(TimedMessage::just_received(mav_message)) .context("Failed to send message")?; + last_receptions.lock().push(Instant::now()); ctx.request_repaint(); } } @@ -156,6 +164,7 @@ impl MessageBroker { // Stop the current listener if it exists self.stop_listening(); self.running_flag.store(true, Ordering::Relaxed); + let last_receptions = Arc::clone(&self.last_receptions); let tx = self.tx.clone(); let ctx = self.ctx.clone(); @@ -186,6 +195,7 @@ impl MessageBroker { debug!("Received message: {:?}", mav_message); tx.send(TimedMessage::just_received(mav_message)) .context("Failed to send message")?; + last_receptions.lock().push(Instant::now()); ctx.request_repaint(); } } @@ -205,8 +215,18 @@ impl MessageBroker { self.messages.clear(); } - fn is_view_subscribed(&self, view_id: ViewId) -> bool { - self.update_queues.contains_key(&view_id) + /// Returns the time since the last message was received. + pub fn time_since_last_reception(&self) -> Option<Duration> { + self.last_receptions.lock().time_since_last_reception() + } + + /// Returns the frequency of messages received in the last second. + pub fn reception_frequency(&self) -> f64 { + self.last_receptions.lock().frequency() + } + + fn is_view_subscribed(&self, widget_id: &ViewId) -> bool { + self.update_queues.contains_key(widget_id) } /// Init a view in case of cache invalidation or first time initialization. @@ -271,3 +291,40 @@ impl Default for ViewId { Self(Uuid::now_v7()) } } + +#[derive(Debug)] +struct ReceptionQueue { + queue: VecDeque<Instant>, + threshold: Duration, +} + +impl ReceptionQueue { + fn new(threshold: Duration) -> Self { + Self { + queue: VecDeque::new(), + threshold, + } + } + + fn push(&mut self, instant: Instant) { + self.queue.push_front(instant); + // clear the queue of all elements older than the threshold + while let Some(front) = self.queue.back() { + if instant.duration_since(*front) > self.threshold { + self.queue.pop_back(); + } else { + break; + } + } + } + + fn frequency(&self) -> f64 { + let till = Instant::now(); + let since = till - self.threshold; + self.queue.iter().take_while(|t| **t > since).count() as f64 / self.threshold.as_secs_f64() + } + + fn time_since_last_reception(&self) -> Option<Duration> { + self.queue.front().map(|t| t.elapsed()) + } +} diff --git a/src/ui.rs b/src/ui.rs index 1f697f5..f2254fc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -4,5 +4,6 @@ mod persistency; mod shortcuts; mod utils; mod widget_gallery; +mod widgets; pub use composable_view::ComposableView; diff --git a/src/ui/composable_view.rs b/src/ui/composable_view.rs index 743f366..87052db 100644 --- a/src/ui/composable_view.rs +++ b/src/ui/composable_view.rs @@ -11,13 +11,15 @@ use super::{ shortcuts, utils::maximized_pane_ui, widget_gallery::WidgetGallery, + widgets::reception_led::ReceptionLed, }; use std::{ fs, path::{Path, PathBuf}, + time::Duration, }; -use egui::{Align2, Button, ComboBox, Key, Modifiers, Vec2}; +use egui::{Align2, Button, ComboBox, Key, Modifiers, Sides, Vec2}; use egui_extras::{Size, StripBuilder}; use egui_tiles::{Behavior, Container, Linear, LinearDir, Tile, TileId, Tiles, Tree}; use serde::{Deserialize, Serialize}; @@ -170,25 +172,45 @@ impl eframe::App for ComposableView { // Show a panel at the bottom of the screen with few global controls egui::TopBottomPanel::bottom("bottom_control").show(ctx, |ui| { // Horizontal belt of controls - ui.horizontal(|ui| { - egui::global_theme_preference_switch(ui); - - // Window for the sources - self.sources_window.show_window(ui); - - if ui.button("Sources").clicked() { - self.sources_window.visible = !self.sources_window.visible; - } - if ui.button("Layout Manager").clicked() { - self.layout_manager_window - .toggle_open_state(&self.layout_manager); - } + Sides::new().show( + ui, + |ui| { + let active = msg_broker!() + .time_since_last_reception() + .unwrap_or(Duration::MAX) + < Duration::from_millis(100); + ui.add(ReceptionLed::new(active)) + }, + |ui| { + ui.horizontal(|ui| { + egui::global_theme_preference_switch(ui); + + // Window for the sources + self.sources_window.show_window(ui); + + if ui + .add(Button::new("🔌").frame(false)) + .on_hover_text("Open the Sources") + .clicked() + { + self.sources_window.visible = !self.sources_window.visible; + } + if ui + .add(Button::new("💾").frame(false)) + .on_hover_text("Open the Layout Manager") + .clicked() + { + self.layout_manager_window + .toggle_open_state(&self.layout_manager); + } - // If a pane is maximized show a visual clue - if self.maximized_pane.is_some() { - ui.label("Pane Maximized!"); - } - }) + // If a pane is maximized show a visual clue + if self.maximized_pane.is_some() { + ui.label("Pane Maximized!"); + } + }); + }, + ); }); // A central panel covers the remainder of the screen, i.e. whatever area is left after adding other panels. @@ -210,6 +232,9 @@ impl eframe::App for ComposableView { debug!("Widget gallery returned action {action:?}"); self.behavior.action = Some(action); } + + // UNCOMMENT THIS TO ENABLE CONTINOUS MODE + // ctx.request_repaint(); } fn save(&mut self, storage: &mut dyn eframe::Storage) { diff --git a/src/ui/composable_view_BACKUP_21160.rs b/src/ui/composable_view_BACKUP_21160.rs new file mode 100644 index 0000000..db7aeb1 --- /dev/null +++ b/src/ui/composable_view_BACKUP_21160.rs @@ -0,0 +1,545 @@ +use crate::{ + error::ErrInstrument, + mavlink, msg_broker, + serial::{get_first_stm32_serial_port, list_all_serial_ports}, + ui::panes::PaneKind, +}; + +use super::{ + panes::{Pane, PaneBehavior}, + persistency::{LayoutManager, LayoutManagerWindow}, + shortcuts, + utils::maximized_pane_ui, + widget_gallery::WidgetGallery, +}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use egui::{Align2, Button, ComboBox, Key, Modifiers, Vec2}; +use egui_extras::{Size, StripBuilder}; +use egui_tiles::{Behavior, Container, Linear, LinearDir, Tile, TileId, Tiles, Tree}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error, trace}; + +#[derive(Default)] +pub struct ComposableView { + /// Persistent state of the app + state: ComposableViewState, + layout_manager: LayoutManager, + widget_gallery: WidgetGallery, + behavior: ComposableBehavior, + maximized_pane: Option<TileId>, + + // == Windows == + sources_window: SourceWindow, + layout_manager_window: LayoutManagerWindow, +} + +// An app must implement the `App` trait to define how the ui is built +impl eframe::App for ComposableView { + // The update function is called each time the UI needs repainting! + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let panes_tree = &mut self.state.panes_tree; + + // Get the id of the hovered pane, in order to apply actions to it + let hovered_pane = panes_tree + .tiles + .iter() + .find(|(_, tile)| matches!(tile, Tile::Pane(pane) if pane.contains_pointer())) + .map(|(id, _)| *id); + trace!("Hovered pane: {:?}", hovered_pane); + + // Capture any pane action generated by pane children + let mut pane_action = self.behavior.action.take(); + trace!("Pane action: {:?}", pane_action); + + if let Some(hovered_tile) = hovered_pane { + // Capture any pane action generated by keyboard shortcuts + let key_action_pairs = [ + ((Modifiers::NONE, Key::V), PaneAction::SplitV), + ((Modifiers::NONE, Key::H), PaneAction::SplitH), + ((Modifiers::NONE, Key::C), PaneAction::Close), + ( + (Modifiers::NONE, Key::R), + PaneAction::ReplaceThroughGallery(Some(hovered_tile)), + ), + ((Modifiers::SHIFT, Key::Escape), PaneAction::Maximize), + ((Modifiers::NONE, Key::Escape), PaneAction::Exit), + ]; + pane_action = pane_action.or(shortcuts::map_to_action(ctx, &key_action_pairs[..])); + } + + // If an action was triggered, we consume it + if let Some(action) = pane_action.take() { + match action { + PaneAction::SplitH => { + if let Some(hovered_tile) = hovered_pane { + if self.maximized_pane.is_none() { + debug!("Called SplitH on tile {:?}", hovered_tile); + let hovered_tile_pane = panes_tree + .tiles + .remove(hovered_tile) + .log_expect("Hovered tile not found"); + let left_pane = panes_tree.tiles.insert_new(hovered_tile_pane); + let right_pane = panes_tree.tiles.insert_pane(Pane::default()); + panes_tree.tiles.insert( + hovered_tile, + Tile::Container(Container::Linear(Linear::new_binary( + LinearDir::Horizontal, + [left_pane, right_pane], + 0.5, + ))), + ); + } + } + } + PaneAction::SplitV => { + if self.maximized_pane.is_none() { + if let Some(hovered_tile) = hovered_pane { + debug!("Called SplitV on tile {:?}", hovered_tile); + let hovered_tile_pane = panes_tree + .tiles + .remove(hovered_tile) + .log_expect("Hovered tile not found"); + let replaced = panes_tree.tiles.insert_new(hovered_tile_pane); + let lower_pane = panes_tree.tiles.insert_pane(Pane::default()); + panes_tree.tiles.insert( + hovered_tile, + Tile::Container(Container::Linear(Linear::new_binary( + LinearDir::Vertical, + [replaced, lower_pane], + 0.5, + ))), + ); + } + } + } + PaneAction::Close => { + if let Some(hovered_tile) = hovered_pane { + debug!("Called Close on tile {:?}", hovered_tile); + // Ignore if the root pane is the only one + if panes_tree.tiles.len() != 1 && self.maximized_pane.is_none() { + panes_tree.remove_recursively(hovered_tile); + } + } + } + PaneAction::Replace(tile_id, new_pane) => { + debug!( + "Called Replace on tile {:?} with pane {:?}", + tile_id, new_pane + ); + panes_tree.tiles.insert(tile_id, Tile::Pane(*new_pane)); + } + PaneAction::ReplaceThroughGallery(Some(source_tile)) => { + self.widget_gallery.replace_tile(source_tile); + } + PaneAction::Maximize => { + // This is a toggle: if there is not currently a maximized pane, + // maximize the hovered pane, otherwize remove the maximized pane. + if self.maximized_pane.is_some() { + self.maximized_pane = None; + } else if let Some(hovered_tile) = hovered_pane { + let hovered_pane_is_default = panes_tree + .tiles + .get(hovered_tile) + .map(|hovered_pane| { + matches!( + hovered_pane, + Tile::Pane(Pane { + pane: PaneKind::Default(_), + }) + ) + }) + .unwrap_or(false); + if !hovered_pane_is_default { + self.maximized_pane = Some(hovered_tile); + } + } + } + PaneAction::Exit => { + if self.maximized_pane.is_some() { + self.maximized_pane = None; + } + } + _ => panic!("Unable to handle action"), + } + } + + // Show a panel at the bottom of the screen with few global controls + egui::TopBottomPanel::bottom("bottom_control").show(ctx, |ui| { + // Horizontal belt of controls + Sides::new().show( + ui, + |ui| { + ui.label("Informative side here!"); + }, + |ui| { + ui.horizontal(|ui| { + egui::global_theme_preference_switch(ui); + + // Window for the sources + self.sources_window.show_window(ui); + +<<<<<<< HEAD + if ui.button("Sources").clicked() { + self.sources_window.visible = !self.sources_window.visible; + } + if ui.button("Layout Manager").clicked() { + self.layout_manager_window + .toggle_open_state(&self.layout_manager); + } + + // If a pane is maximized show a visual clue + if self.maximized_pane.is_some() { + ui.label("Pane Maximized!"); + } + }) +======= + if ui + .add(Button::new("🔌").frame(false)) + .on_hover_text("Open the Sources") + .clicked() + { + self.sources_window.visible = !self.sources_window.visible; + } + if ui + .add(Button::new("💾").frame(false)) + .on_hover_text("Open the Layout Manager") + .clicked() + { + self.layout_manager_window + .toggle_open_state(&self.layout_manager); + } + }); + }, + ); +>>>>>>> 9dc29a7 ([StatusBar] added right side (action bar) with icons instead of text) + }); + + // A central panel covers the remainder of the screen, i.e. whatever area is left after adding other panels. + egui::CentralPanel::default().show(ctx, |ui| { + if let Some(maximized_pane) = self.maximized_pane { + if let Some(Tile::Pane(pane)) = panes_tree.tiles.get_mut(maximized_pane) { + maximized_pane_ui(ui, maximized_pane, pane); + } else { + panic!("Maximized pane not found in tree!"); + } + } else { + panes_tree.ui(&mut self.behavior, ui); + } + }); + + self.layout_manager_window + .show(ctx, &mut self.layout_manager, &mut self.state); + if let Some(action) = self.widget_gallery.show(ctx) { + debug!("Widget gallery returned action {action:?}"); + self.behavior.action = Some(action); + } + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + self.layout_manager.save_current_layout(storage); + } +} + +impl ComposableView { + pub fn new(app_name: &str, storage: &dyn eframe::Storage) -> Self { + let layout_manager = LayoutManager::new(app_name, storage); + let mut s = Self { + layout_manager, + ..Self::default() + }; + // Load the selected layout if valid and existing + if let Some(layout) = s.layout_manager.current_layout().cloned() { + s.layout_manager + .load_layout(layout, &mut s.state) + .unwrap_or_else(|e| { + error!("Error loading layout: {}", e); + }); + } + s + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +pub struct ComposableViewState { + pub panes_tree: Tree<Pane>, +} + +impl Default for ComposableViewState { + fn default() -> Self { + let mut tiles = Tiles::default(); + let root = tiles.insert_pane(Pane::default()); + let panes_tree = egui_tiles::Tree::new("main_tree", root, tiles); + + Self { panes_tree } + } +} + +impl ComposableViewState { + pub fn from_file(path: &PathBuf) -> anyhow::Result<Self> { + fs::read_to_string(path) + .and_then(|json| serde_json::from_str::<ComposableViewState>(&json).map_err(Into::into)) + .map_err(|e| anyhow::anyhow!("Error deserializing layout: {}", e)) + } + + pub fn to_file(&self, path: &Path) -> anyhow::Result<()> { + // Check if the parent path exists, if not create it + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| anyhow::anyhow!("Error creating directory: {}", e))?; + debug!("Created directory {:?}", parent); + } + } + + let serialized_layout = serde_json::to_string_pretty(self) + .map_err(|e| anyhow::anyhow!("Error serializing layout: {}", e))?; + debug!("Serialized layout: {}", serialized_layout); + fs::write(path, serialized_layout) + .map_err(|e| anyhow::anyhow!("Error writing layout: {}", e))?; + + Ok(()) + } +} + +#[derive(Debug, PartialEq, Eq, Default)] +enum ConnectionKind { + #[default] + Ethernet, + Serial, +} + +#[derive(Debug)] +enum ConnectionDetails { + Ethernet { port: u16 }, + Serial { port: String, baud_rate: u32 }, +} + +impl Default for ConnectionDetails { + fn default() -> Self { + ConnectionDetails::Ethernet { + port: mavlink::DEFAULT_ETHERNET_PORT, + } + } +} + +#[derive(Debug, Default)] +struct SourceWindow { + visible: bool, + connected: bool, + connection_kind: ConnectionKind, + connection_details: ConnectionDetails, +} + +impl SourceWindow { + fn show_window(&mut self, ui: &mut egui::Ui) { + let mut window_is_open = self.visible; + let mut can_be_closed = false; + egui::Window::new("Sources") + .id(ui.id()) +<<<<<<< HEAD + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .max_width(200.0) + .collapsible(false) + .resizable(false) +======= + .auto_sized() + .collapsible(false) + .movable(false) + .anchor(Align2::CENTER_CENTER, (0.0, 0.0)) +>>>>>>> 9dc29a7 ([StatusBar] added right side (action bar) with icons instead of text) + .open(&mut window_is_open) + .show(ui.ctx(), |ui| { + self.ui(ui, &mut can_be_closed); + }); + self.visible = window_is_open && !can_be_closed; + } + + fn ui(&mut self, ui: &mut egui::Ui, can_be_closed: &mut bool) { + let SourceWindow { + connected, + connection_kind, + connection_details, + .. + } = self; + ui.label("Select Source:"); + ui.horizontal_top(|ui| { + ui.radio_value(connection_kind, ConnectionKind::Ethernet, "Ethernet"); + ui.radio_value(connection_kind, ConnectionKind::Serial, "Serial"); + }); + + ui.separator(); + + match *connection_kind { + ConnectionKind::Ethernet => { + if !matches!(connection_details, ConnectionDetails::Ethernet { .. }) { + *connection_details = ConnectionDetails::Ethernet { + port: mavlink::DEFAULT_ETHERNET_PORT, + }; + } + let ConnectionDetails::Ethernet { port } = connection_details else { + error!("UNREACHABLE: Connection kind is not Ethernet"); + unreachable!("Connection kind is not Ethernet"); + }; + + egui::Grid::new("grid") + .num_columns(2) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.label("Ethernet Port:"); + ui.add(egui::DragValue::new(port).range(0..=65535).speed(10)); + ui.end_row(); + }); + } + ConnectionKind::Serial => { + if !matches!(connection_details, ConnectionDetails::Serial { .. }) { + *connection_details = ConnectionDetails::Serial { + // Default to the first STM32 serial port if available, otherwise + // default to the first serial port available + port: get_first_stm32_serial_port().unwrap_or( + list_all_serial_ports() + .ok() + .and_then(|ports| ports.first().cloned()) + .unwrap_or_default(), + ), + baud_rate: 115200, + }; + } + let ConnectionDetails::Serial { port, baud_rate } = connection_details else { + error!("UNREACHABLE: Connection kind is not Serial"); + unreachable!("Connection kind is not Serial"); + }; + + egui::Grid::new("grid") + .num_columns(2) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.label("Serial Port:"); + ComboBox::from_id_salt("serial_port") + .selected_text(port.clone()) + .show_ui(ui, |ui| { + for available_port in list_all_serial_ports().unwrap_or_default() { + ui.selectable_value( + port, + available_port.clone(), + available_port, + ); + } + }); + ui.end_row(); + ui.label("Baud Rate:"); + ui.add( + egui::DragValue::new(baud_rate) + .range(110..=256000) + .speed(100), + ); + ui.end_row(); + }); + } + }; + + ui.separator(); + + ui.allocate_ui(Vec2::new(ui.available_width(), 20.0), |ui| { + StripBuilder::new(ui) + .sizes(Size::remainder(), 2) // top cell + .horizontal(|mut strip| { + strip.cell(|ui| { + let btn1 = Button::new("Connect"); + ui.add_enabled_ui(!*connected, |ui| { + if ui.add_sized(ui.available_size(), btn1).clicked() { + match connection_details { + ConnectionDetails::Ethernet { port } => { + msg_broker!().listen_from_ethernet_port(*port); + } + ConnectionDetails::Serial { port, baud_rate } => { + msg_broker!() + .listen_from_serial_port(port.clone(), *baud_rate); + } + } + *can_be_closed = true; + *connected = true; + } + }); + }); + strip.cell(|ui| { + let btn2 = Button::new("Disconnect"); + ui.add_enabled_ui(*connected, |ui| { + if ui.add_sized(ui.available_size(), btn2).clicked() { + msg_broker!().stop_listening(); + *connected = false; + } + }); + }); + }); + }); + } +} + +/// Behavior for the tree of panes in the composable view +#[derive(Default)] +pub struct ComposableBehavior { + pub action: Option<PaneAction>, +} + +impl Behavior<Pane> for ComposableBehavior { + fn pane_ui( + &mut self, + ui: &mut egui::Ui, + tile_id: TileId, + pane: &mut Pane, + ) -> egui_tiles::UiResponse { + let PaneResponse { + action_called, + drag_response, + } = pane.ui(ui, tile_id); + // Capture the action and store it to be consumed in the update function + if let Some(action_called) = action_called { + self.action = Some(action_called); + } + drag_response + } + + fn tab_title_for_pane(&mut self, _pane: &Pane) -> egui::WidgetText { + "Tab".into() + } +} + +#[derive(Clone, Debug)] +pub struct PaneResponse { + pub action_called: Option<PaneAction>, + pub drag_response: egui_tiles::UiResponse, +} + +impl PaneResponse { + pub fn set_action(&mut self, action: PaneAction) { + self.action_called = Some(action); + } + + pub fn set_drag_started(&mut self) { + self.drag_response = egui_tiles::UiResponse::DragStarted; + } +} + +impl Default for PaneResponse { + fn default() -> Self { + Self { + action_called: None, + drag_response: egui_tiles::UiResponse::None, + } + } +} + +#[derive(Clone, Debug)] +pub enum PaneAction { + SplitH, + SplitV, + Close, + Replace(TileId, Box<Pane>), + ReplaceThroughGallery(Option<TileId>), + Maximize, + Exit, +} diff --git a/src/ui/composable_view_BASE_21160.rs b/src/ui/composable_view_BASE_21160.rs new file mode 100644 index 0000000..8ba208a --- /dev/null +++ b/src/ui/composable_view_BASE_21160.rs @@ -0,0 +1,318 @@ +use crate::{error::ErrInstrument, mavlink, msg_broker}; + +use super::{ + panes::{Pane, PaneBehavior}, + persistency::{LayoutManager, LayoutManagerWindow}, + shortcuts, +}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use egui::{Key, Modifiers}; +use egui_tiles::{Behavior, Container, Linear, LinearDir, Tile, TileId, Tiles, Tree}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error, trace}; + +#[derive(Default)] +pub struct ComposableView { + /// Persistent state of the app + state: ComposableViewState, + layout_manager: LayoutManager, + behavior: ComposableBehavior, + + // == Windows == + sources_window: SourceWindow, + layout_manager_window: LayoutManagerWindow, +} + +// An app must implement the `App` trait to define how the ui is built +impl eframe::App for ComposableView { + // The update function is called each time the UI needs repainting! + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let panes_tree = &mut self.state.panes_tree; + + // Get the id of the hovered pane, in order to apply actions to it + let hovered_pane = panes_tree + .tiles + .iter() + .find(|(_, tile)| matches!(tile, Tile::Pane(pane) if pane.contains_pointer())) + .map(|(id, _)| *id); + trace!("Hovered pane: {:?}", hovered_pane); + + // Capture any pane action generated by pane children + let pane_action = self.behavior.action.take(); + let mut pane_action = pane_action.zip(hovered_pane); + trace!("Pane action: {:?}", pane_action); + + // Capture any pane action generated by keyboard shortcuts + if let Some(hovered_pane) = hovered_pane { + let key_action_pairs = [ + ((Modifiers::NONE, Key::V), PaneAction::SplitV), + ((Modifiers::NONE, Key::H), PaneAction::SplitH), + ((Modifiers::NONE, Key::C), PaneAction::Close), + ]; + pane_action = pane_action.or(shortcuts::map_to_action(ctx, &key_action_pairs[..]) + .map(|action| (action, hovered_pane))); + } + + // If an action was triggered, we consume it + if let Some((action, hovered_tile)) = pane_action.take() { + match action { + PaneAction::SplitH => { + debug!("Called SplitH on tile {:?}", hovered_tile); + let hovered_tile_pane = panes_tree + .tiles + .remove(hovered_tile) + .log_expect("Hovered tile not found"); + let left_pane = panes_tree.tiles.insert_new(hovered_tile_pane); + let right_pane = panes_tree.tiles.insert_pane(Pane::default()); + panes_tree.tiles.insert( + hovered_tile, + Tile::Container(Container::Linear(Linear::new_binary( + LinearDir::Horizontal, + [left_pane, right_pane], + 0.5, + ))), + ); + } + PaneAction::SplitV => { + debug!("Called SplitV on tile {:?}", hovered_tile); + let hovered_tile_pane = panes_tree + .tiles + .remove(hovered_tile) + .log_expect("Hovered tile not found"); + let replaced = panes_tree.tiles.insert_new(hovered_tile_pane); + let lower_pane = panes_tree.tiles.insert_pane(Pane::default()); + panes_tree.tiles.insert( + hovered_tile, + Tile::Container(Container::Linear(Linear::new_binary( + LinearDir::Vertical, + [replaced, lower_pane], + 0.5, + ))), + ); + } + PaneAction::Close => { + debug!("Called Close on tile {:?}", hovered_tile); + // Ignore if the root pane is the only one + if panes_tree.tiles.len() != 1 { + panes_tree.remove_recursively(hovered_tile); + } + } + PaneAction::Replace(new_pane) => { + debug!( + "Called Replace on tile {:?} with pane {:?}", + hovered_tile, new_pane + ); + panes_tree.tiles.insert(hovered_tile, Tile::Pane(*new_pane)); + } + } + } + + // Show a panel at the bottom of the screen with few global controls + egui::TopBottomPanel::bottom("bottom_control").show(ctx, |ui| { + // Horizontal belt of controls + ui.horizontal(|ui| { + egui::global_theme_preference_switch(ui); + + // Window for the sources + self.sources_window.show_window(ui); + + if ui.button("Sources").clicked() { + self.sources_window.visible = !self.sources_window.visible; + } + if ui.button("Layout Manager").clicked() { + self.layout_manager_window + .toggle_open_state(&self.layout_manager); + } + }) + }); + + // A central panel covers the remainder of the screen, i.e. whatever area is left after adding other panels. + egui::CentralPanel::default().show(ctx, |ui| { + panes_tree.ui(&mut self.behavior, ui); + }); + + self.layout_manager_window + .show(ctx, &mut self.layout_manager, &mut self.state); + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + self.layout_manager.save_current_layout(storage); + } +} + +impl ComposableView { + pub fn new(app_name: &str, storage: &dyn eframe::Storage) -> Self { + let layout_manager = LayoutManager::new(app_name, storage); + let mut s = Self { + layout_manager, + ..Self::default() + }; + // Load the selected layout if valid and existing + if let Some(layout) = s.layout_manager.current_layout().cloned() { + s.layout_manager + .load_layout(layout, &mut s.state) + .unwrap_or_else(|e| { + error!("Error loading layout: {}", e); + }); + } + s + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +pub struct ComposableViewState { + pub panes_tree: Tree<Pane>, +} + +impl Default for ComposableViewState { + fn default() -> Self { + let mut tiles = Tiles::default(); + let root = tiles.insert_pane(Pane::default()); + let panes_tree = egui_tiles::Tree::new("main_tree", root, tiles); + + Self { panes_tree } + } +} + +impl ComposableViewState { + pub fn from_file(path: &PathBuf) -> anyhow::Result<Self> { + fs::read_to_string(path) + .and_then(|json| serde_json::from_str::<ComposableViewState>(&json).map_err(Into::into)) + .map_err(|e| anyhow::anyhow!("Error deserializing layout: {}", e)) + } + + pub fn to_file(&self, path: &Path) -> anyhow::Result<()> { + // Check if the parent path exists, if not create it + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| anyhow::anyhow!("Error creating directory: {}", e))?; + debug!("Created directory {:?}", parent); + } + } + + let serialized_layout = serde_json::to_string_pretty(self) + .map_err(|e| anyhow::anyhow!("Error serializing layout: {}", e))?; + debug!("Serialized layout: {}", serialized_layout); + fs::write(path, serialized_layout) + .map_err(|e| anyhow::anyhow!("Error writing layout: {}", e))?; + + Ok(()) + } +} + +struct SourceWindow { + port: u16, + visible: bool, +} + +impl Default for SourceWindow { + fn default() -> Self { + Self { + port: mavlink::DEFAULT_ETHERNET_PORT, + visible: false, + } + } +} + +impl SourceWindow { + fn show_window(&mut self, ui: &mut egui::Ui) { + let mut window_is_open = self.visible; + let mut can_be_closed = false; + egui::Window::new("Sources") + .id(ui.id()) + .auto_sized() + .collapsible(true) + .movable(true) + .open(&mut window_is_open) + .show(ui.ctx(), |ui| { + self.ui(ui, &mut can_be_closed); + }); + self.visible = window_is_open && !can_be_closed; + } + + fn ui(&mut self, ui: &mut egui::Ui, can_be_closed: &mut bool) { + egui::Grid::new(ui.id()) + .num_columns(2) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.label("Ethernet Port:"); + ui.add( + egui::DragValue::new(&mut self.port) + .range(0..=65535) + .speed(10), + ); + ui.end_row(); + }); + if ui.button("Connect").clicked() { + msg_broker!().listen_from_ethernet_port(self.port); + *can_be_closed = true; + } + } +} + +/// Behavior for the tree of panes in the composable view +#[derive(Default)] +pub struct ComposableBehavior { + pub action: Option<PaneAction>, +} + +impl Behavior<Pane> for ComposableBehavior { + fn pane_ui( + &mut self, + ui: &mut egui::Ui, + _tile_id: TileId, + pane: &mut Pane, + ) -> egui_tiles::UiResponse { + let PaneResponse { + action_called, + drag_response, + } = pane.ui(ui); + // Capture the action and store it to be consumed in the update function + if let Some(action_called) = action_called { + self.action = Some(action_called); + } + drag_response + } + + fn tab_title_for_pane(&mut self, _pane: &Pane) -> egui::WidgetText { + "Tab".into() + } +} + +#[derive(Clone, Debug)] +pub struct PaneResponse { + pub action_called: Option<PaneAction>, + pub drag_response: egui_tiles::UiResponse, +} + +impl PaneResponse { + pub fn set_action(&mut self, action: PaneAction) { + self.action_called = Some(action); + } + + pub fn set_drag_started(&mut self) { + self.drag_response = egui_tiles::UiResponse::DragStarted; + } +} + +impl Default for PaneResponse { + fn default() -> Self { + Self { + action_called: None, + drag_response: egui_tiles::UiResponse::None, + } + } +} + +#[derive(Clone, Debug)] +pub enum PaneAction { + SplitH, + SplitV, + Close, + Replace(Box<Pane>), +} diff --git a/src/ui/composable_view_LOCAL_21160.rs b/src/ui/composable_view_LOCAL_21160.rs new file mode 100644 index 0000000..743f366 --- /dev/null +++ b/src/ui/composable_view_LOCAL_21160.rs @@ -0,0 +1,511 @@ +use crate::{ + error::ErrInstrument, + mavlink, msg_broker, + serial::{get_first_stm32_serial_port, list_all_serial_ports}, + ui::panes::PaneKind, +}; + +use super::{ + panes::{Pane, PaneBehavior}, + persistency::{LayoutManager, LayoutManagerWindow}, + shortcuts, + utils::maximized_pane_ui, + widget_gallery::WidgetGallery, +}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use egui::{Align2, Button, ComboBox, Key, Modifiers, Vec2}; +use egui_extras::{Size, StripBuilder}; +use egui_tiles::{Behavior, Container, Linear, LinearDir, Tile, TileId, Tiles, Tree}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error, trace}; + +#[derive(Default)] +pub struct ComposableView { + /// Persistent state of the app + state: ComposableViewState, + layout_manager: LayoutManager, + widget_gallery: WidgetGallery, + behavior: ComposableBehavior, + maximized_pane: Option<TileId>, + + // == Windows == + sources_window: SourceWindow, + layout_manager_window: LayoutManagerWindow, +} + +// An app must implement the `App` trait to define how the ui is built +impl eframe::App for ComposableView { + // The update function is called each time the UI needs repainting! + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let panes_tree = &mut self.state.panes_tree; + + // Get the id of the hovered pane, in order to apply actions to it + let hovered_pane = panes_tree + .tiles + .iter() + .find(|(_, tile)| matches!(tile, Tile::Pane(pane) if pane.contains_pointer())) + .map(|(id, _)| *id); + trace!("Hovered pane: {:?}", hovered_pane); + + // Capture any pane action generated by pane children + let mut pane_action = self.behavior.action.take(); + trace!("Pane action: {:?}", pane_action); + + if let Some(hovered_tile) = hovered_pane { + // Capture any pane action generated by keyboard shortcuts + let key_action_pairs = [ + ((Modifiers::NONE, Key::V), PaneAction::SplitV), + ((Modifiers::NONE, Key::H), PaneAction::SplitH), + ((Modifiers::NONE, Key::C), PaneAction::Close), + ( + (Modifiers::NONE, Key::R), + PaneAction::ReplaceThroughGallery(Some(hovered_tile)), + ), + ((Modifiers::SHIFT, Key::Escape), PaneAction::Maximize), + ((Modifiers::NONE, Key::Escape), PaneAction::Exit), + ]; + pane_action = pane_action.or(shortcuts::map_to_action(ctx, &key_action_pairs[..])); + } + + // If an action was triggered, we consume it + if let Some(action) = pane_action.take() { + match action { + PaneAction::SplitH => { + if let Some(hovered_tile) = hovered_pane { + if self.maximized_pane.is_none() { + debug!("Called SplitH on tile {:?}", hovered_tile); + let hovered_tile_pane = panes_tree + .tiles + .remove(hovered_tile) + .log_expect("Hovered tile not found"); + let left_pane = panes_tree.tiles.insert_new(hovered_tile_pane); + let right_pane = panes_tree.tiles.insert_pane(Pane::default()); + panes_tree.tiles.insert( + hovered_tile, + Tile::Container(Container::Linear(Linear::new_binary( + LinearDir::Horizontal, + [left_pane, right_pane], + 0.5, + ))), + ); + } + } + } + PaneAction::SplitV => { + if self.maximized_pane.is_none() { + if let Some(hovered_tile) = hovered_pane { + debug!("Called SplitV on tile {:?}", hovered_tile); + let hovered_tile_pane = panes_tree + .tiles + .remove(hovered_tile) + .log_expect("Hovered tile not found"); + let replaced = panes_tree.tiles.insert_new(hovered_tile_pane); + let lower_pane = panes_tree.tiles.insert_pane(Pane::default()); + panes_tree.tiles.insert( + hovered_tile, + Tile::Container(Container::Linear(Linear::new_binary( + LinearDir::Vertical, + [replaced, lower_pane], + 0.5, + ))), + ); + } + } + } + PaneAction::Close => { + if let Some(hovered_tile) = hovered_pane { + debug!("Called Close on tile {:?}", hovered_tile); + // Ignore if the root pane is the only one + if panes_tree.tiles.len() != 1 && self.maximized_pane.is_none() { + panes_tree.remove_recursively(hovered_tile); + } + } + } + PaneAction::Replace(tile_id, new_pane) => { + debug!( + "Called Replace on tile {:?} with pane {:?}", + tile_id, new_pane + ); + panes_tree.tiles.insert(tile_id, Tile::Pane(*new_pane)); + } + PaneAction::ReplaceThroughGallery(Some(source_tile)) => { + self.widget_gallery.replace_tile(source_tile); + } + PaneAction::Maximize => { + // This is a toggle: if there is not currently a maximized pane, + // maximize the hovered pane, otherwize remove the maximized pane. + if self.maximized_pane.is_some() { + self.maximized_pane = None; + } else if let Some(hovered_tile) = hovered_pane { + let hovered_pane_is_default = panes_tree + .tiles + .get(hovered_tile) + .map(|hovered_pane| { + matches!( + hovered_pane, + Tile::Pane(Pane { + pane: PaneKind::Default(_), + }) + ) + }) + .unwrap_or(false); + if !hovered_pane_is_default { + self.maximized_pane = Some(hovered_tile); + } + } + } + PaneAction::Exit => { + if self.maximized_pane.is_some() { + self.maximized_pane = None; + } + } + _ => panic!("Unable to handle action"), + } + } + + // Show a panel at the bottom of the screen with few global controls + egui::TopBottomPanel::bottom("bottom_control").show(ctx, |ui| { + // Horizontal belt of controls + ui.horizontal(|ui| { + egui::global_theme_preference_switch(ui); + + // Window for the sources + self.sources_window.show_window(ui); + + if ui.button("Sources").clicked() { + self.sources_window.visible = !self.sources_window.visible; + } + if ui.button("Layout Manager").clicked() { + self.layout_manager_window + .toggle_open_state(&self.layout_manager); + } + + // If a pane is maximized show a visual clue + if self.maximized_pane.is_some() { + ui.label("Pane Maximized!"); + } + }) + }); + + // A central panel covers the remainder of the screen, i.e. whatever area is left after adding other panels. + egui::CentralPanel::default().show(ctx, |ui| { + if let Some(maximized_pane) = self.maximized_pane { + if let Some(Tile::Pane(pane)) = panes_tree.tiles.get_mut(maximized_pane) { + maximized_pane_ui(ui, maximized_pane, pane); + } else { + panic!("Maximized pane not found in tree!"); + } + } else { + panes_tree.ui(&mut self.behavior, ui); + } + }); + + self.layout_manager_window + .show(ctx, &mut self.layout_manager, &mut self.state); + if let Some(action) = self.widget_gallery.show(ctx) { + debug!("Widget gallery returned action {action:?}"); + self.behavior.action = Some(action); + } + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + self.layout_manager.save_current_layout(storage); + } +} + +impl ComposableView { + pub fn new(app_name: &str, storage: &dyn eframe::Storage) -> Self { + let layout_manager = LayoutManager::new(app_name, storage); + let mut s = Self { + layout_manager, + ..Self::default() + }; + // Load the selected layout if valid and existing + if let Some(layout) = s.layout_manager.current_layout().cloned() { + s.layout_manager + .load_layout(layout, &mut s.state) + .unwrap_or_else(|e| { + error!("Error loading layout: {}", e); + }); + } + s + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +pub struct ComposableViewState { + pub panes_tree: Tree<Pane>, +} + +impl Default for ComposableViewState { + fn default() -> Self { + let mut tiles = Tiles::default(); + let root = tiles.insert_pane(Pane::default()); + let panes_tree = egui_tiles::Tree::new("main_tree", root, tiles); + + Self { panes_tree } + } +} + +impl ComposableViewState { + pub fn from_file(path: &PathBuf) -> anyhow::Result<Self> { + fs::read_to_string(path) + .and_then(|json| serde_json::from_str::<ComposableViewState>(&json).map_err(Into::into)) + .map_err(|e| anyhow::anyhow!("Error deserializing layout: {}", e)) + } + + pub fn to_file(&self, path: &Path) -> anyhow::Result<()> { + // Check if the parent path exists, if not create it + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| anyhow::anyhow!("Error creating directory: {}", e))?; + debug!("Created directory {:?}", parent); + } + } + + let serialized_layout = serde_json::to_string_pretty(self) + .map_err(|e| anyhow::anyhow!("Error serializing layout: {}", e))?; + debug!("Serialized layout: {}", serialized_layout); + fs::write(path, serialized_layout) + .map_err(|e| anyhow::anyhow!("Error writing layout: {}", e))?; + + Ok(()) + } +} + +#[derive(Debug, PartialEq, Eq, Default)] +enum ConnectionKind { + #[default] + Ethernet, + Serial, +} + +#[derive(Debug)] +enum ConnectionDetails { + Ethernet { port: u16 }, + Serial { port: String, baud_rate: u32 }, +} + +impl Default for ConnectionDetails { + fn default() -> Self { + ConnectionDetails::Ethernet { + port: mavlink::DEFAULT_ETHERNET_PORT, + } + } +} + +#[derive(Debug, Default)] +struct SourceWindow { + visible: bool, + connected: bool, + connection_kind: ConnectionKind, + connection_details: ConnectionDetails, +} + +impl SourceWindow { + fn show_window(&mut self, ui: &mut egui::Ui) { + let mut window_is_open = self.visible; + let mut can_be_closed = false; + egui::Window::new("Sources") + .id(ui.id()) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .max_width(200.0) + .collapsible(false) + .resizable(false) + .open(&mut window_is_open) + .show(ui.ctx(), |ui| { + self.ui(ui, &mut can_be_closed); + }); + self.visible = window_is_open && !can_be_closed; + } + + fn ui(&mut self, ui: &mut egui::Ui, can_be_closed: &mut bool) { + let SourceWindow { + connected, + connection_kind, + connection_details, + .. + } = self; + ui.label("Select Source:"); + ui.horizontal_top(|ui| { + ui.radio_value(connection_kind, ConnectionKind::Ethernet, "Ethernet"); + ui.radio_value(connection_kind, ConnectionKind::Serial, "Serial"); + }); + + ui.separator(); + + match *connection_kind { + ConnectionKind::Ethernet => { + if !matches!(connection_details, ConnectionDetails::Ethernet { .. }) { + *connection_details = ConnectionDetails::Ethernet { + port: mavlink::DEFAULT_ETHERNET_PORT, + }; + } + let ConnectionDetails::Ethernet { port } = connection_details else { + error!("UNREACHABLE: Connection kind is not Ethernet"); + unreachable!("Connection kind is not Ethernet"); + }; + + egui::Grid::new("grid") + .num_columns(2) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.label("Ethernet Port:"); + ui.add(egui::DragValue::new(port).range(0..=65535).speed(10)); + ui.end_row(); + }); + } + ConnectionKind::Serial => { + if !matches!(connection_details, ConnectionDetails::Serial { .. }) { + *connection_details = ConnectionDetails::Serial { + // Default to the first STM32 serial port if available, otherwise + // default to the first serial port available + port: get_first_stm32_serial_port().unwrap_or( + list_all_serial_ports() + .ok() + .and_then(|ports| ports.first().cloned()) + .unwrap_or_default(), + ), + baud_rate: 115200, + }; + } + let ConnectionDetails::Serial { port, baud_rate } = connection_details else { + error!("UNREACHABLE: Connection kind is not Serial"); + unreachable!("Connection kind is not Serial"); + }; + + egui::Grid::new("grid") + .num_columns(2) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.label("Serial Port:"); + ComboBox::from_id_salt("serial_port") + .selected_text(port.clone()) + .show_ui(ui, |ui| { + for available_port in list_all_serial_ports().unwrap_or_default() { + ui.selectable_value( + port, + available_port.clone(), + available_port, + ); + } + }); + ui.end_row(); + ui.label("Baud Rate:"); + ui.add( + egui::DragValue::new(baud_rate) + .range(110..=256000) + .speed(100), + ); + ui.end_row(); + }); + } + }; + + ui.separator(); + + ui.allocate_ui(Vec2::new(ui.available_width(), 20.0), |ui| { + StripBuilder::new(ui) + .sizes(Size::remainder(), 2) // top cell + .horizontal(|mut strip| { + strip.cell(|ui| { + let btn1 = Button::new("Connect"); + ui.add_enabled_ui(!*connected, |ui| { + if ui.add_sized(ui.available_size(), btn1).clicked() { + match connection_details { + ConnectionDetails::Ethernet { port } => { + msg_broker!().listen_from_ethernet_port(*port); + } + ConnectionDetails::Serial { port, baud_rate } => { + msg_broker!() + .listen_from_serial_port(port.clone(), *baud_rate); + } + } + *can_be_closed = true; + *connected = true; + } + }); + }); + strip.cell(|ui| { + let btn2 = Button::new("Disconnect"); + ui.add_enabled_ui(*connected, |ui| { + if ui.add_sized(ui.available_size(), btn2).clicked() { + msg_broker!().stop_listening(); + *connected = false; + } + }); + }); + }); + }); + } +} + +/// Behavior for the tree of panes in the composable view +#[derive(Default)] +pub struct ComposableBehavior { + pub action: Option<PaneAction>, +} + +impl Behavior<Pane> for ComposableBehavior { + fn pane_ui( + &mut self, + ui: &mut egui::Ui, + tile_id: TileId, + pane: &mut Pane, + ) -> egui_tiles::UiResponse { + let PaneResponse { + action_called, + drag_response, + } = pane.ui(ui, tile_id); + // Capture the action and store it to be consumed in the update function + if let Some(action_called) = action_called { + self.action = Some(action_called); + } + drag_response + } + + fn tab_title_for_pane(&mut self, _pane: &Pane) -> egui::WidgetText { + "Tab".into() + } +} + +#[derive(Clone, Debug)] +pub struct PaneResponse { + pub action_called: Option<PaneAction>, + pub drag_response: egui_tiles::UiResponse, +} + +impl PaneResponse { + pub fn set_action(&mut self, action: PaneAction) { + self.action_called = Some(action); + } + + pub fn set_drag_started(&mut self) { + self.drag_response = egui_tiles::UiResponse::DragStarted; + } +} + +impl Default for PaneResponse { + fn default() -> Self { + Self { + action_called: None, + drag_response: egui_tiles::UiResponse::None, + } + } +} + +#[derive(Clone, Debug)] +pub enum PaneAction { + SplitH, + SplitV, + Close, + Replace(TileId, Box<Pane>), + ReplaceThroughGallery(Option<TileId>), + Maximize, + Exit, +} diff --git a/src/ui/composable_view_REMOTE_21160.rs b/src/ui/composable_view_REMOTE_21160.rs new file mode 100644 index 0000000..1aa4151 --- /dev/null +++ b/src/ui/composable_view_REMOTE_21160.rs @@ -0,0 +1,336 @@ +use crate::{error::ErrInstrument, mavlink, msg_broker}; + +use super::{ + panes::{Pane, PaneBehavior}, + persistency::{LayoutManager, LayoutManagerWindow}, + shortcuts, +}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use egui::{Align2, Button, Key, Modifiers, Sides}; +use egui_extras::{Size, StripBuilder}; +use egui_tiles::{Behavior, Container, Linear, LinearDir, Tile, TileId, Tiles, Tree}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error, trace}; + +#[derive(Default)] +pub struct ComposableView { + /// Persistent state of the app + state: ComposableViewState, + layout_manager: LayoutManager, + behavior: ComposableBehavior, + + // == Windows == + sources_window: SourceWindow, + layout_manager_window: LayoutManagerWindow, +} + +// An app must implement the `App` trait to define how the ui is built +impl eframe::App for ComposableView { + // The update function is called each time the UI needs repainting! + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let panes_tree = &mut self.state.panes_tree; + + // Get the id of the hovered pane, in order to apply actions to it + let hovered_pane = panes_tree + .tiles + .iter() + .find(|(_, tile)| matches!(tile, Tile::Pane(pane) if pane.contains_pointer())) + .map(|(id, _)| *id); + trace!("Hovered pane: {:?}", hovered_pane); + + // Capture any pane action generated by pane children + let pane_action = self.behavior.action.take(); + let mut pane_action = pane_action.zip(hovered_pane); + trace!("Pane action: {:?}", pane_action); + + // Capture any pane action generated by keyboard shortcuts + if let Some(hovered_pane) = hovered_pane { + let key_action_pairs = [ + ((Modifiers::NONE, Key::V), PaneAction::SplitV), + ((Modifiers::NONE, Key::H), PaneAction::SplitH), + ((Modifiers::NONE, Key::C), PaneAction::Close), + ]; + pane_action = pane_action.or(shortcuts::map_to_action(ctx, &key_action_pairs[..]) + .map(|action| (action, hovered_pane))); + } + + // If an action was triggered, we consume it + if let Some((action, hovered_tile)) = pane_action.take() { + match action { + PaneAction::SplitH => { + debug!("Called SplitH on tile {:?}", hovered_tile); + let hovered_tile_pane = panes_tree + .tiles + .remove(hovered_tile) + .log_expect("Hovered tile not found"); + let left_pane = panes_tree.tiles.insert_new(hovered_tile_pane); + let right_pane = panes_tree.tiles.insert_pane(Pane::default()); + panes_tree.tiles.insert( + hovered_tile, + Tile::Container(Container::Linear(Linear::new_binary( + LinearDir::Horizontal, + [left_pane, right_pane], + 0.5, + ))), + ); + } + PaneAction::SplitV => { + debug!("Called SplitV on tile {:?}", hovered_tile); + let hovered_tile_pane = panes_tree + .tiles + .remove(hovered_tile) + .log_expect("Hovered tile not found"); + let replaced = panes_tree.tiles.insert_new(hovered_tile_pane); + let lower_pane = panes_tree.tiles.insert_pane(Pane::default()); + panes_tree.tiles.insert( + hovered_tile, + Tile::Container(Container::Linear(Linear::new_binary( + LinearDir::Vertical, + [replaced, lower_pane], + 0.5, + ))), + ); + } + PaneAction::Close => { + debug!("Called Close on tile {:?}", hovered_tile); + // Ignore if the root pane is the only one + if panes_tree.tiles.len() != 1 { + panes_tree.remove_recursively(hovered_tile); + } + } + PaneAction::Replace(new_pane) => { + debug!( + "Called Replace on tile {:?} with pane {:?}", + hovered_tile, new_pane + ); + panes_tree.tiles.insert(hovered_tile, Tile::Pane(*new_pane)); + } + } + } + + // Show a panel at the bottom of the screen with few global controls + egui::TopBottomPanel::bottom("bottom_control").show(ctx, |ui| { + // Horizontal belt of controls + Sides::new().show( + ui, + |ui| { + ui.label("Informative side here!"); + }, + |ui| { + ui.horizontal(|ui| { + egui::global_theme_preference_switch(ui); + + // Window for the sources + self.sources_window.show_window(ui); + + if ui + .add(Button::new("🔌").frame(false)) + .on_hover_text("Open the Sources") + .clicked() + { + self.sources_window.visible = !self.sources_window.visible; + } + if ui + .add(Button::new("💾").frame(false)) + .on_hover_text("Open the Layout Manager") + .clicked() + { + self.layout_manager_window + .toggle_open_state(&self.layout_manager); + } + }); + }, + ); + }); + + // A central panel covers the remainder of the screen, i.e. whatever area is left after adding other panels. + egui::CentralPanel::default().show(ctx, |ui| { + panes_tree.ui(&mut self.behavior, ui); + }); + + self.layout_manager_window + .show(ctx, &mut self.layout_manager, &mut self.state); + } + + fn save(&mut self, storage: &mut dyn eframe::Storage) { + self.layout_manager.save_current_layout(storage); + } +} + +impl ComposableView { + pub fn new(app_name: &str, storage: &dyn eframe::Storage) -> Self { + let layout_manager = LayoutManager::new(app_name, storage); + let mut s = Self { + layout_manager, + ..Self::default() + }; + // Load the selected layout if valid and existing + if let Some(layout) = s.layout_manager.current_layout().cloned() { + s.layout_manager + .load_layout(layout, &mut s.state) + .unwrap_or_else(|e| { + error!("Error loading layout: {}", e); + }); + } + s + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq)] +pub struct ComposableViewState { + pub panes_tree: Tree<Pane>, +} + +impl Default for ComposableViewState { + fn default() -> Self { + let mut tiles = Tiles::default(); + let root = tiles.insert_pane(Pane::default()); + let panes_tree = egui_tiles::Tree::new("main_tree", root, tiles); + + Self { panes_tree } + } +} + +impl ComposableViewState { + pub fn from_file(path: &PathBuf) -> anyhow::Result<Self> { + fs::read_to_string(path) + .and_then(|json| serde_json::from_str::<ComposableViewState>(&json).map_err(Into::into)) + .map_err(|e| anyhow::anyhow!("Error deserializing layout: {}", e)) + } + + pub fn to_file(&self, path: &Path) -> anyhow::Result<()> { + // Check if the parent path exists, if not create it + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| anyhow::anyhow!("Error creating directory: {}", e))?; + debug!("Created directory {:?}", parent); + } + } + + let serialized_layout = serde_json::to_string_pretty(self) + .map_err(|e| anyhow::anyhow!("Error serializing layout: {}", e))?; + debug!("Serialized layout: {}", serialized_layout); + fs::write(path, serialized_layout) + .map_err(|e| anyhow::anyhow!("Error writing layout: {}", e))?; + + Ok(()) + } +} + +struct SourceWindow { + port: u16, + visible: bool, +} + +impl Default for SourceWindow { + fn default() -> Self { + Self { + port: mavlink::DEFAULT_ETHERNET_PORT, + visible: false, + } + } +} + +impl SourceWindow { + fn show_window(&mut self, ui: &mut egui::Ui) { + let mut window_is_open = self.visible; + let mut can_be_closed = false; + egui::Window::new("Sources") + .id(ui.id()) + .auto_sized() + .collapsible(false) + .movable(false) + .anchor(Align2::CENTER_CENTER, (0.0, 0.0)) + .open(&mut window_is_open) + .show(ui.ctx(), |ui| { + self.ui(ui, &mut can_be_closed); + }); + self.visible = window_is_open && !can_be_closed; + } + + fn ui(&mut self, ui: &mut egui::Ui, can_be_closed: &mut bool) { + egui::Grid::new(ui.id()) + .num_columns(2) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.label("Ethernet Port:"); + ui.add( + egui::DragValue::new(&mut self.port) + .range(0..=65535) + .speed(10), + ); + ui.end_row(); + }); + if ui.button("Connect").clicked() { + msg_broker!().listen_from_ethernet_port(self.port); + *can_be_closed = true; + } + } +} + +/// Behavior for the tree of panes in the composable view +#[derive(Default)] +pub struct ComposableBehavior { + pub action: Option<PaneAction>, +} + +impl Behavior<Pane> for ComposableBehavior { + fn pane_ui( + &mut self, + ui: &mut egui::Ui, + _tile_id: TileId, + pane: &mut Pane, + ) -> egui_tiles::UiResponse { + let PaneResponse { + action_called, + drag_response, + } = pane.ui(ui); + // Capture the action and store it to be consumed in the update function + if let Some(action_called) = action_called { + self.action = Some(action_called); + } + drag_response + } + + fn tab_title_for_pane(&mut self, _pane: &Pane) -> egui::WidgetText { + "Tab".into() + } +} + +#[derive(Clone, Debug)] +pub struct PaneResponse { + pub action_called: Option<PaneAction>, + pub drag_response: egui_tiles::UiResponse, +} + +impl PaneResponse { + pub fn set_action(&mut self, action: PaneAction) { + self.action_called = Some(action); + } + + pub fn set_drag_started(&mut self) { + self.drag_response = egui_tiles::UiResponse::DragStarted; + } +} + +impl Default for PaneResponse { + fn default() -> Self { + Self { + action_called: None, + drag_response: egui_tiles::UiResponse::None, + } + } +} + +#[derive(Clone, Debug)] +pub enum PaneAction { + SplitH, + SplitV, + Close, + Replace(Box<Pane>), +} diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs new file mode 100644 index 0000000..0504bca --- /dev/null +++ b/src/ui/widgets.rs @@ -0,0 +1 @@ +pub mod reception_led; diff --git a/src/ui/widgets/reception_led.rs b/src/ui/widgets/reception_led.rs new file mode 100644 index 0000000..e92f5f1 --- /dev/null +++ b/src/ui/widgets/reception_led.rs @@ -0,0 +1,58 @@ +use egui::{Color32, Response, Sense, Stroke, Ui, Vec2, Widget}; + +use crate::{error::ErrInstrument, msg_broker}; + +pub struct ReceptionLed { + pub active: bool, +} + +impl ReceptionLed { + /// Create a new `ReceptionLed` widget based on the given state. + pub fn new(active: bool) -> Self { + Self { active } + } +} + +impl ReceptionLed { + fn show_led(&self, ui: &mut Ui) -> Response { + // Allocate an exact size for the widget + let (rect, response) = ui.allocate_exact_size(Vec2::splat(9.0), Sense::click()); + // Get the visuals for the UI (to display the widget with coherent style) + // in this case we use the visuals for inactive widgets, since this is a passive component + let visuals = ui.style().visuals.widgets.noninteractive; + let inactive_bg = Color32::TRANSPARENT; + let active_bg = Color32::from_hex("#03C04A").log_unwrap(); + + // Determine colors based on state + let fill_color = if self.active { active_bg } else { inactive_bg }; + let stroke = Stroke::new(1.0, visuals.fg_stroke.color); + + // Use the painter to draw a rectangle + if ui.is_rect_visible(rect) { + ui.painter().rect(rect, 1.0, fill_color, stroke); + } + + response + } + + fn show_label(&self, ui: &mut Ui) -> Response { + if self.active { + let freq = msg_broker!().reception_frequency(); + let text = format!("{} Hz", freq); + ui.label(text) + } else { + ui.label("N/A") + } + } +} + +impl Widget for ReceptionLed { + fn ui(self, ui: &mut Ui) -> Response { + ui.horizontal(|ui| { + ui.label("Receiving at:"); + self.show_led(ui); + self.show_label(ui); + }) + .response + } +} -- GitLab