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