From 56cc306d8b08e5ed8149ae153bfd676dba632f26 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Thu, 20 Mar 2025 19:04:21 +0100
Subject: [PATCH 01/24] Fixed Pid name

---
 src/ui/panes.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index 977fbda..33f01ce 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -90,7 +90,7 @@ pub enum PaneKind {
     Plot2D(plot::Plot2DPane),
 
     #[strum(message = "Pid")]
-    PidOld(pid_drawing_tool::PidPane),
+    Pid(pid_drawing_tool::PidPane),
 }
 
 impl Default for PaneKind {
-- 
GitLab


From cb68e832beed79eb11ba3908eafaae5329646732 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Thu, 20 Mar 2025 19:04:27 +0100
Subject: [PATCH 02/24] Fixed Pid pane draggability

---
 src/ui/panes/pid_drawing_tool.rs | 16 ++++++++++++++--
 1 file changed, 14 insertions(+), 2 deletions(-)

diff --git a/src/ui/panes/pid_drawing_tool.rs b/src/ui/panes/pid_drawing_tool.rs
index f4e980e..57c49ca 100644
--- a/src/ui/panes/pid_drawing_tool.rs
+++ b/src/ui/panes/pid_drawing_tool.rs
@@ -53,6 +53,8 @@ pub struct PidPane {
     editable: bool,
     #[serde(skip)]
     is_subs_window_visible: bool,
+    #[serde(skip)]
+    contains_pointer: bool,
 }
 
 impl Default for PidPane {
@@ -66,6 +68,7 @@ impl Default for PidPane {
             action: None,
             editable: false,
             is_subs_window_visible: false,
+            contains_pointer: false,
         }
     }
 }
@@ -81,6 +84,8 @@ impl PartialEq for PidPane {
 
 impl PaneBehavior for PidPane {
     fn ui(&mut self, ui: &mut egui::Ui, _: TileId) -> PaneResponse {
+        let mut pane_response = PaneResponse::default();
+
         let theme = PidPane::find_theme(ui.ctx());
 
         if self.center_content && !self.editable {
@@ -132,11 +137,18 @@ impl PaneBehavior for PidPane {
             self.reset_subscriptions();
         }
 
-        PaneResponse::default()
+        // Check if the user is draqging the pane
+        self.contains_pointer = response.contains_pointer();
+        let ctrl_pressed = ui.input(|i| i.modifiers.ctrl);
+        if response.dragged() && (ctrl_pressed || !self.editable) {
+            pane_response.set_drag_started();
+        }
+
+        pane_response
     }
 
     fn contains_pointer(&self) -> bool {
-        false
+        self.contains_pointer
     }
 
     fn update(&mut self, messages: &[TimedMessage]) {
-- 
GitLab


From 74a26643abb015b1483f0fb74dee5541b4b7d40d Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Thu, 20 Mar 2025 20:01:58 +0100
Subject: [PATCH 03/24] CHECKPOINT

---
 src/ui/panes.rs                     |  6 ++-
 src/ui/panes/plot.rs                |  2 +-
 src/ui/panes/valve_control.rs       | 25 +++++++++++
 src/ui/panes/valve_control/enums.rs | 68 +++++++++++++++++++++++++++++
 4 files changed, 99 insertions(+), 2 deletions(-)
 create mode 100644 src/ui/panes/valve_control.rs
 create mode 100644 src/ui/panes/valve_control/enums.rs

diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index 33f01ce..9b27514 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -1,7 +1,8 @@
 mod default;
 mod messages_viewer;
 mod pid_drawing_tool;
-pub mod plot;
+mod plot;
+mod valve_control;
 
 use egui_tiles::TileId;
 use enum_dispatch::enum_dispatch;
@@ -91,6 +92,9 @@ pub enum PaneKind {
 
     #[strum(message = "Pid")]
     Pid(pid_drawing_tool::PidPane),
+
+    #[strum(message = "Valve Control")]
+    ValveControl(valve_control::ValveControlPane),
 }
 
 impl Default for PaneKind {
diff --git a/src/ui/panes/plot.rs b/src/ui/panes/plot.rs
index 4a98372..684f221 100644
--- a/src/ui/panes/plot.rs
+++ b/src/ui/panes/plot.rs
@@ -33,7 +33,7 @@ pub struct Plot2DPane {
     #[serde(skip)]
     settings_visible: bool,
     #[serde(skip)]
-    pub contains_pointer: bool,
+    contains_pointer: bool,
 }
 
 impl PartialEq for Plot2DPane {
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
new file mode 100644
index 0000000..a46ff41
--- /dev/null
+++ b/src/ui/panes/valve_control.rs
@@ -0,0 +1,25 @@
+use egui_tiles::TileId;
+use serde::{Deserialize, Serialize};
+
+use crate::ui::app::PaneResponse;
+
+use super::PaneBehavior;
+
+mod enums;
+
+#[derive(Clone, PartialEq, Default, Serialize, Deserialize, Debug)]
+pub struct ValveControlPane {
+    // Temporary Internal state
+    #[serde(skip)]
+    contains_pointer: bool,
+}
+
+impl PaneBehavior for ValveControlPane {
+    fn ui(&mut self, ui: &mut egui::Ui, tile_id: TileId) -> PaneResponse {
+        todo!()
+    }
+
+    fn contains_pointer(&self) -> bool {
+        self.contains_pointer
+    }
+}
diff --git a/src/ui/panes/valve_control/enums.rs b/src/ui/panes/valve_control/enums.rs
new file mode 100644
index 0000000..a1bf8fc
--- /dev/null
+++ b/src/ui/panes/valve_control/enums.rs
@@ -0,0 +1,68 @@
+use std::fmt::Display;
+
+use strum_macros::EnumIter;
+
+use crate::mavlink::{
+    MessageData, SET_ATOMIC_VALVE_TIMING_TC_DATA, SET_VALVE_MAXIMUM_APERTURE_TC_DATA, Servoslist,
+};
+
+#[allow(non_camel_case_types)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter)]
+pub enum Valve {
+    OxFilling,
+    OxRelease,
+    OxVenting,
+    N2Filling,
+    N2Release,
+    N2Quenching,
+    N23Way,
+    Main,
+    Nitrogen,
+}
+
+impl From<Valve> for Servoslist {
+    fn from(valve: Valve) -> Servoslist {
+        match valve {
+            Valve::OxFilling => Servoslist::OX_FILLING_VALVE,
+            Valve::OxRelease => Servoslist::OX_RELEASE_VALVE,
+            Valve::OxVenting => Servoslist::OX_VENTING_VALVE,
+            Valve::N2Filling => Servoslist::N2_FILLING_VALVE,
+            Valve::N2Release => Servoslist::N2_RELEASE_VALVE,
+            Valve::N2Quenching => Servoslist::N2_QUENCHING_VALVE,
+            Valve::N23Way => Servoslist::N2_3WAY_VALVE,
+            Valve::Main => Servoslist::MAIN_VALVE,
+            Valve::Nitrogen => Servoslist::NITROGEN_VALVE,
+        }
+    }
+}
+
+impl Display for Valve {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Valve::OxFilling => write!(f, "Oxidizer Filling"),
+            Valve::OxRelease => write!(f, "Oxidizer Release"),
+            Valve::OxVenting => write!(f, "Oxidizer Venting"),
+            Valve::N2Filling => write!(f, "Nitrogen Filling"),
+            Valve::N2Release => write!(f, "Nitrogen Release"),
+            Valve::N2Quenching => write!(f, "Nitrogen Quenching"),
+            Valve::N23Way => write!(f, "Nitrogen 3-Way"),
+            Valve::Main => write!(f, "Main"),
+            Valve::Nitrogen => write!(f, "Nitrogen"),
+        }
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter)]
+pub enum ValveCommands {
+    AtomicValveTiming,
+    ValveMaximumAperture,
+}
+
+impl From<ValveCommands> for u32 {
+    fn from(command: ValveCommands) -> u32 {
+        match command {
+            ValveCommands::AtomicValveTiming => SET_ATOMIC_VALVE_TIMING_TC_DATA::ID,
+            ValveCommands::ValveMaximumAperture => SET_VALVE_MAXIMUM_APERTURE_TC_DATA::ID,
+        }
+    }
+}
-- 
GitLab


From aced424cad06c57e47d58a73a831187978855b50 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Fri, 21 Mar 2025 09:54:39 +0100
Subject: [PATCH 04/24] Added support for multiple message id subscriptions

---
 src/mavlink.rs                       |  4 ++++
 src/message_broker.rs                | 19 +++++++--------
 src/message_broker/message_bundle.rs | 36 +++++++---------------------
 src/ui/app.rs                        |  8 +++----
 src/ui/panes.rs                      | 12 +++++-----
 src/ui/panes/default.rs              | 15 +++++++-----
 src/ui/panes/pid_drawing_tool.rs     |  6 ++---
 src/ui/panes/plot.rs                 |  6 ++---
 8 files changed, 45 insertions(+), 61 deletions(-)

diff --git a/src/mavlink.rs b/src/mavlink.rs
index f1517cc..9a05aa8 100644
--- a/src/mavlink.rs
+++ b/src/mavlink.rs
@@ -34,4 +34,8 @@ impl TimedMessage {
             time: Instant::now(),
         }
     }
+
+    pub fn id(&self) -> u32 {
+        self.message.message_id()
+    }
 }
diff --git a/src/message_broker.rs b/src/message_broker.rs
index 7cf10e7..ddf1c17 100644
--- a/src/message_broker.rs
+++ b/src/message_broker.rs
@@ -11,7 +11,6 @@ pub use message_bundle::MessageBundle;
 use reception_queue::ReceptionQueue;
 
 use std::{
-    collections::HashMap,
     sync::{Arc, Mutex},
     time::Duration,
 };
@@ -21,7 +20,7 @@ use tracing::error;
 use crate::{
     communication::{Connection, ConnectionError, TransceiverConfigExt},
     error::ErrInstrument,
-    mavlink::{MavFrame, MavHeader, MavMessage, MavlinkVersion, Message, TimedMessage},
+    mavlink::{MavFrame, MavHeader, MavMessage, MavlinkVersion, TimedMessage},
 };
 
 const RECEPTION_QUEUE_INTERVAL: Duration = Duration::from_secs(1);
@@ -34,7 +33,7 @@ const SEGS_COMPONENT_ID: u8 = 1;
 /// dispatching them to the views that are interested in them.
 pub struct MessageBroker {
     /// A map of all messages received so far, indexed by message ID
-    messages: HashMap<u32, Vec<TimedMessage>>,
+    messages: Vec<TimedMessage>,
     /// instant queue used for frequency calculation and reception time
     last_receptions: Arc<Mutex<ReceptionQueue>>,
     /// Connection to the Mavlink listener
@@ -47,7 +46,7 @@ impl MessageBroker {
     /// Creates a new `MessageBroker` with the given channel size and Egui context.
     pub fn new(ctx: egui::Context) -> Self {
         Self {
-            messages: HashMap::new(),
+            messages: Vec::new(),
             // TODO: make this configurable
             last_receptions: Arc::new(Mutex::new(ReceptionQueue::new(RECEPTION_QUEUE_INTERVAL))),
             connection: None,
@@ -88,8 +87,11 @@ impl MessageBroker {
         self.last_receptions.lock().log_unwrap().frequency()
     }
 
-    pub fn get(&self, id: u32) -> &[TimedMessage] {
-        self.messages.get(&id).map_or(&[], |v| v.as_slice())
+    pub fn get(&self, ids: &[u32]) -> Vec<&TimedMessage> {
+        self.messages
+            .iter()
+            .filter(|msg| ids.contains(&msg.id()))
+            .collect()
     }
 
     /// Processes incoming network messages. New messages are added to the
@@ -108,10 +110,7 @@ impl MessageBroker {
                         self.last_receptions.lock().log_unwrap().push(message.time);
 
                         // Store the message in the broker
-                        self.messages
-                            .entry(message.message.message_id())
-                            .or_default()
-                            .push(message);
+                        self.messages.push(message);
                     }
                     self.ctx.request_repaint();
                 }
diff --git a/src/message_broker/message_bundle.rs b/src/message_broker/message_bundle.rs
index 52568b9..eaa06df 100644
--- a/src/message_broker/message_bundle.rs
+++ b/src/message_broker/message_bundle.rs
@@ -1,4 +1,4 @@
-use crate::mavlink::{Message, TimedMessage};
+use crate::mavlink::TimedMessage;
 
 /// A bundle of messages, indexed by their ID.
 /// Allows for efficient storage and retrieval of messages by ID.
@@ -10,36 +10,22 @@ use crate::mavlink::{Message, TimedMessage};
 /// method to clear the content of the bundle and prepare it for reuse.
 #[derive(Default)]
 pub struct MessageBundle {
-    storage: Vec<(u32, Vec<TimedMessage>)>,
+    storage: Vec<TimedMessage>,
     count: u32,
 }
 
 impl MessageBundle {
     /// Returns all messages of the given ID contained in the bundle.
-    pub fn get(&self, id: u32) -> &[TimedMessage] {
+    pub fn get(&self, ids: &[u32]) -> Vec<&TimedMessage> {
         self.storage
             .iter()
-            .find(|&&(queue_id, _)| queue_id == id)
-            .map_or(&[], |(_, messages)| messages.as_slice())
+            .filter(|msg| ids.contains(&msg.id()))
+            .collect()
     }
 
     /// Inserts a new message into the bundle.
     pub fn insert(&mut self, message: TimedMessage) {
-        let message_id = message.message.message_id();
-
-        // Retrieve the queue for the ID, if it exists
-        let maybe_queue = self
-            .storage
-            .iter_mut()
-            .find(|&&mut (queue_id, _)| queue_id == message_id)
-            .map(|(_, queue)| queue);
-
-        if let Some(queue) = maybe_queue {
-            queue.push(message);
-        } else {
-            self.storage.push((message_id, vec![message]));
-        }
-
+        self.storage.push(message);
         self.count += 1;
     }
 
@@ -49,15 +35,9 @@ impl MessageBundle {
     }
 
     /// Resets the content of the bundle, preparing it to be efficiently reused.
-    /// Effectively, it clears the content of the bundle, but with lower
-    /// allocation cost the next time the bundle is reused.
+    /// Effectively, it clears the content of the bundle.
     pub fn reset(&mut self) {
-        // Clear the individual queues instead of the full storage, to avoid
-        // the allocation cost of the already used per-id queues.
-        for (_, queue) in &mut self.storage {
-            queue.clear();
-        }
-
+        self.storage.clear();
         self.count = 0;
     }
 }
diff --git a/src/ui/app.rs b/src/ui/app.rs
index ad8fa9f..902e586 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -318,14 +318,12 @@ impl App {
             // Skip non-pane tiles
             let Tile::Pane(pane) = tile else { continue };
             // Skip panes that do not have a subscription
-            let Some(sub_id) = pane.get_message_subscription() else {
-                continue;
-            };
+            let sub_ids: Vec<u32> = pane.get_message_subscriptions().collect();
 
             if pane.should_send_message_history() {
-                pane.update(self.message_broker.get(sub_id));
+                pane.update(self.message_broker.get(&sub_ids[..]).as_slice());
             } else {
-                pane.update(self.message_bundle.get(sub_id));
+                pane.update(self.message_bundle.get(&sub_ids[..]).as_slice());
             }
         }
 
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index 9b27514..5d031f3 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -34,11 +34,11 @@ pub trait PaneBehavior {
 
     /// Updates the pane state. This method is called before `ui` to allow the
     /// pane to update its state based on the messages received.
-    fn update(&mut self, _messages: &[TimedMessage]) {}
+    fn update(&mut self, _messages: &[&TimedMessage]) {}
 
     /// Returns the ID of the messages this pane is interested in, if any.
-    fn get_message_subscription(&self) -> Option<u32> {
-        None
+    fn get_message_subscriptions(&self) -> Box<dyn Iterator<Item = u32>> {
+        Box::new(None.into_iter())
     }
 
     /// Checks whether the full message history should be sent to the pane.
@@ -61,12 +61,12 @@ impl PaneBehavior for Pane {
         self.pane.contains_pointer()
     }
 
-    fn update(&mut self, messages: &[TimedMessage]) {
+    fn update(&mut self, messages: &[&TimedMessage]) {
         self.pane.update(messages)
     }
 
-    fn get_message_subscription(&self) -> Option<u32> {
-        self.pane.get_message_subscription()
+    fn get_message_subscriptions(&self) -> Box<dyn Iterator<Item = u32>> {
+        self.pane.get_message_subscriptions()
     }
 
     fn should_send_message_history(&self) -> bool {
diff --git a/src/ui/panes/default.rs b/src/ui/panes/default.rs
index c3f7979..e4d1bef 100644
--- a/src/ui/panes/default.rs
+++ b/src/ui/panes/default.rs
@@ -2,9 +2,12 @@ use super::PaneBehavior;
 use serde::{Deserialize, Serialize};
 use tracing::debug;
 
-use crate::ui::{
-    app::{PaneAction, PaneResponse},
-    utils::{SizingMemo, vertically_centered},
+use crate::{
+    mavlink::TimedMessage,
+    ui::{
+        app::{PaneAction, PaneResponse},
+        utils::{SizingMemo, vertically_centered},
+    },
 };
 
 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -60,10 +63,10 @@ impl PaneBehavior for DefaultPane {
         self.contains_pointer
     }
 
-    fn update(&mut self, _messages: &[crate::mavlink::TimedMessage]) {}
+    fn update(&mut self, _messages: &[&TimedMessage]) {}
 
-    fn get_message_subscription(&self) -> Option<u32> {
-        None
+    fn get_message_subscriptions(&self) -> Box<dyn Iterator<Item = u32>> {
+        Box::new(None.into_iter())
     }
 
     fn should_send_message_history(&self) -> bool {
diff --git a/src/ui/panes/pid_drawing_tool.rs b/src/ui/panes/pid_drawing_tool.rs
index 57c49ca..6f7695e 100644
--- a/src/ui/panes/pid_drawing_tool.rs
+++ b/src/ui/panes/pid_drawing_tool.rs
@@ -151,7 +151,7 @@ impl PaneBehavior for PidPane {
         self.contains_pointer
     }
 
-    fn update(&mut self, messages: &[TimedMessage]) {
+    fn update(&mut self, messages: &[&TimedMessage]) {
         if let Some(msg) = messages.last() {
             for element in &mut self.elements {
                 element.update(&msg.message, self.message_subscription_id);
@@ -159,8 +159,8 @@ impl PaneBehavior for PidPane {
         }
     }
 
-    fn get_message_subscription(&self) -> Option<u32> {
-        Some(self.message_subscription_id)
+    fn get_message_subscriptions(&self) -> Box<dyn Iterator<Item = u32>> {
+        Box::new(Some(self.message_subscription_id).into_iter())
     }
 }
 
diff --git a/src/ui/panes/plot.rs b/src/ui/panes/plot.rs
index 684f221..42444ab 100644
--- a/src/ui/panes/plot.rs
+++ b/src/ui/panes/plot.rs
@@ -186,7 +186,7 @@ impl PaneBehavior for Plot2DPane {
     }
 
     #[profiling::function]
-    fn update(&mut self, messages: &[TimedMessage]) {
+    fn update(&mut self, messages: &[&TimedMessage]) {
         if !self.state_valid {
             self.line_data.clear();
         }
@@ -226,8 +226,8 @@ impl PaneBehavior for Plot2DPane {
         self.state_valid = true;
     }
 
-    fn get_message_subscription(&self) -> Option<u32> {
-        Some(self.settings.plot_message_id)
+    fn get_message_subscriptions(&self) -> Box<dyn Iterator<Item = u32>> {
+        Box::new(Some(self.settings.plot_message_id).into_iter())
     }
 
     fn should_send_message_history(&self) -> bool {
-- 
GitLab


From f26c5392effa9994c9fd6c2afa66573efad65d77 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Fri, 21 Mar 2025 10:11:49 +0100
Subject: [PATCH 05/24] Simplified ui method of PaneBehavior

Removed TileId reference
---
 src/ui/app.rs                    | 31 +++++++++++++++----------------
 src/ui/panes.rs                  |  8 ++++----
 src/ui/panes/default.rs          |  5 +++--
 src/ui/panes/messages_viewer.rs  |  2 +-
 src/ui/panes/pid_drawing_tool.rs |  3 +--
 src/ui/panes/plot.rs             |  7 +++----
 src/ui/panes/valve_control.rs    |  3 +--
 src/ui/utils.rs                  |  5 ++---
 src/ui/widget_gallery.rs         |  4 ++--
 9 files changed, 32 insertions(+), 36 deletions(-)

diff --git a/src/ui/app.rs b/src/ui/app.rs
index 902e586..199d195 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -66,18 +66,18 @@ impl eframe::App for App {
                 ((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::NONE, Key::R), PaneAction::ReplaceThroughGallery),
                 ((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[..]));
+            pane_action =
+                pane_action
+                    .or(shortcuts::map_to_action(ctx, &key_action_pairs[..])
+                        .map(|a| (hovered_tile, a)));
         }
 
         // If an action was triggered, we consume it
-        if let Some(action) = pane_action.take() {
+        if let Some((tile_id, action)) = pane_action.take() {
             match action {
                 PaneAction::SplitH => {
                     if let Some(hovered_tile) = hovered_pane {
@@ -132,15 +132,15 @@ impl eframe::App for App {
                         }
                     }
                 }
-                PaneAction::Replace(tile_id, new_pane) => {
+                PaneAction::Replace(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::ReplaceThroughGallery => {
+                    self.widget_gallery.replace_tile(tile_id);
                 }
                 PaneAction::Maximize => {
                     // This is a toggle: if there is not currently a maximized pane,
@@ -170,7 +170,6 @@ impl eframe::App for App {
                         self.maximized_pane = None;
                     }
                 }
-                _ => panic!("Unable to handle action"),
             }
         }
 
@@ -225,7 +224,7 @@ impl eframe::App for App {
         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);
+                    maximized_pane_ui(ui, pane);
                 } else {
                     panic!("Maximized pane not found in tree!");
                 }
@@ -400,7 +399,7 @@ impl AppState {
 /// Behavior for the tree of panes in the app
 #[derive(Default)]
 pub struct AppBehavior {
-    pub action: Option<PaneAction>,
+    pub action: Option<(TileId, PaneAction)>,
 }
 
 impl Behavior<Pane> for AppBehavior {
@@ -413,10 +412,10 @@ impl Behavior<Pane> for AppBehavior {
         let PaneResponse {
             action_called,
             drag_response,
-        } = pane.ui(ui, tile_id);
+        } = 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);
+            self.action = Some((tile_id, action_called));
         }
         drag_response
     }
@@ -456,8 +455,8 @@ pub enum PaneAction {
     SplitH,
     SplitV,
     Close,
-    Replace(TileId, Box<Pane>),
-    ReplaceThroughGallery(Option<TileId>),
+    Replace(Box<Pane>),
+    ReplaceThroughGallery,
     Maximize,
     Exit,
 }
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index 5d031f3..1bec4df 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -4,7 +4,7 @@ mod pid_drawing_tool;
 mod plot;
 mod valve_control;
 
-use egui_tiles::TileId;
+use egui::Ui;
 use enum_dispatch::enum_dispatch;
 use serde::{Deserialize, Serialize};
 use strum_macros::{self, EnumIter, EnumMessage};
@@ -27,7 +27,7 @@ impl Pane {
 #[enum_dispatch(PaneKind)]
 pub trait PaneBehavior {
     /// Renders the UI of the pane.
-    fn ui(&mut self, ui: &mut egui::Ui, tile_id: TileId) -> PaneResponse;
+    fn ui(&mut self, ui: &mut Ui) -> PaneResponse;
 
     /// Whether the pane contains the pointer.
     fn contains_pointer(&self) -> bool;
@@ -53,8 +53,8 @@ pub trait PaneBehavior {
 }
 
 impl PaneBehavior for Pane {
-    fn ui(&mut self, ui: &mut egui::Ui, tile_id: TileId) -> PaneResponse {
-        self.pane.ui(ui, tile_id)
+    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
+        self.pane.ui(ui)
     }
 
     fn contains_pointer(&self) -> bool {
diff --git a/src/ui/panes/default.rs b/src/ui/panes/default.rs
index e4d1bef..63eb9d7 100644
--- a/src/ui/panes/default.rs
+++ b/src/ui/panes/default.rs
@@ -1,4 +1,5 @@
 use super::PaneBehavior;
+use egui::Ui;
 use serde::{Deserialize, Serialize};
 use tracing::debug;
 
@@ -26,7 +27,7 @@ impl PartialEq for DefaultPane {
 
 impl PaneBehavior for DefaultPane {
     #[profiling::function]
-    fn ui(&mut self, ui: &mut egui::Ui, tile_id: egui_tiles::TileId) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
         let mut response = PaneResponse::default();
 
         let parent = vertically_centered(ui, &mut self.centering_memo, |ui| {
@@ -40,7 +41,7 @@ impl PaneBehavior for DefaultPane {
                     debug!("Horizontal Split button clicked");
                 }
                 if ui.button("Widget Gallery").clicked() {
-                    response.set_action(PaneAction::ReplaceThroughGallery(Some(tile_id)));
+                    response.set_action(PaneAction::ReplaceThroughGallery);
                 }
             })
             .response
diff --git a/src/ui/panes/messages_viewer.rs b/src/ui/panes/messages_viewer.rs
index 2c8a54f..628f2d1 100644
--- a/src/ui/panes/messages_viewer.rs
+++ b/src/ui/panes/messages_viewer.rs
@@ -19,7 +19,7 @@ impl PartialEq for MessagesViewerPane {
 
 impl PaneBehavior for MessagesViewerPane {
     #[profiling::function]
-    fn ui(&mut self, ui: &mut egui::Ui, _tile_id: egui_tiles::TileId) -> PaneResponse {
+    fn ui(&mut self, ui: &mut egui::Ui) -> PaneResponse {
         let mut response = PaneResponse::default();
         let label = ui.add_sized(ui.available_size(), Label::new("This is a label"));
         self.contains_pointer = label.contains_pointer();
diff --git a/src/ui/panes/pid_drawing_tool.rs b/src/ui/panes/pid_drawing_tool.rs
index 6f7695e..1a7ec73 100644
--- a/src/ui/panes/pid_drawing_tool.rs
+++ b/src/ui/panes/pid_drawing_tool.rs
@@ -8,7 +8,6 @@ use core::f32;
 use egui::{
     Button, Color32, Context, CursorIcon, PointerButton, Response, Sense, Theme, Ui, Widget,
 };
-use egui_tiles::TileId;
 use elements::Element;
 use glam::Vec2;
 use grid::GridInfo;
@@ -83,7 +82,7 @@ impl PartialEq for PidPane {
 }
 
 impl PaneBehavior for PidPane {
-    fn ui(&mut self, ui: &mut egui::Ui, _: TileId) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
         let mut pane_response = PaneResponse::default();
 
         let theme = PidPane::find_theme(ui.ctx());
diff --git a/src/ui/panes/plot.rs b/src/ui/panes/plot.rs
index 42444ab..0d67e3d 100644
--- a/src/ui/panes/plot.rs
+++ b/src/ui/panes/plot.rs
@@ -11,9 +11,8 @@ use crate::{
     ui::app::PaneResponse,
     utils::units::UnitOfMeasure,
 };
-use egui::{Color32, Vec2, Vec2b};
+use egui::{Color32, Ui, Vec2, Vec2b};
 use egui_plot::{AxisHints, HPlacement, Legend, Line, PlotPoint, log_grid_spacer};
-use egui_tiles::TileId;
 use serde::{self, Deserialize, Serialize};
 use source_window::sources_window;
 use std::{
@@ -44,7 +43,7 @@ impl PartialEq for Plot2DPane {
 
 impl PaneBehavior for Plot2DPane {
     #[profiling::function]
-    fn ui(&mut self, ui: &mut egui::Ui, _: TileId) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
         let mut response = PaneResponse::default();
         let data_settings_digest = self.settings.data_digest();
 
@@ -235,7 +234,7 @@ impl PaneBehavior for Plot2DPane {
     }
 }
 
-fn show_menu(ui: &mut egui::Ui, settings_visible: &mut bool, settings: &mut PlotSettings) {
+fn show_menu(ui: &mut Ui, settings_visible: &mut bool, settings: &mut PlotSettings) {
     ui.set_max_width(200.0); // To make sure we wrap long text
 
     if ui.button("Source Data Settings…").clicked() {
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index a46ff41..f2e83cc 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -1,4 +1,3 @@
-use egui_tiles::TileId;
 use serde::{Deserialize, Serialize};
 
 use crate::ui::app::PaneResponse;
@@ -15,7 +14,7 @@ pub struct ValveControlPane {
 }
 
 impl PaneBehavior for ValveControlPane {
-    fn ui(&mut self, ui: &mut egui::Ui, tile_id: TileId) -> PaneResponse {
+    fn ui(&mut self, ui: &mut egui::Ui) -> PaneResponse {
         todo!()
     }
 
diff --git a/src/ui/utils.rs b/src/ui/utils.rs
index 0c2ae3f..2469c54 100644
--- a/src/ui/utils.rs
+++ b/src/ui/utils.rs
@@ -1,17 +1,16 @@
 use egui::containers::Frame;
 use egui::{Response, Shadow, Stroke, Style, Ui};
-use egui_tiles::TileId;
 
 use super::panes::{Pane, PaneBehavior};
 
 /// This function wraps a ui into a popup frame intended for the pane that needs
 /// to be maximized on screen.
-pub fn maximized_pane_ui(ui: &mut Ui, tile_id: TileId, pane: &mut Pane) {
+pub fn maximized_pane_ui(ui: &mut Ui, pane: &mut Pane) {
     Frame::popup(&Style::default())
         .fill(egui::Color32::TRANSPARENT)
         .shadow(Shadow::NONE)
         .stroke(Stroke::NONE)
-        .show(ui, |ui| pane.ui(ui, tile_id));
+        .show(ui, |ui| pane.ui(ui));
 }
 
 #[derive(Debug, Default, Clone)]
diff --git a/src/ui/widget_gallery.rs b/src/ui/widget_gallery.rs
index ad8796d..80ef01e 100644
--- a/src/ui/widget_gallery.rs
+++ b/src/ui/widget_gallery.rs
@@ -19,7 +19,7 @@ impl WidgetGallery {
         self.open = true;
     }
 
-    pub fn show(&mut self, ctx: &Context) -> Option<PaneAction> {
+    pub fn show(&mut self, ctx: &Context) -> Option<(TileId, PaneAction)> {
         let mut window_visible = self.open;
         let resp = egui::Window::new("Widget Gallery")
             .collapsible(false)
@@ -31,7 +31,7 @@ impl WidgetGallery {
                     } else if let Some(message) = pane.get_message() {
                         if ui.button(message).clicked() {
                             if let Some(tile_id) = self.tile_id {
-                                return Some(PaneAction::Replace(tile_id, Pane::boxed(pane)));
+                                return Some((tile_id, PaneAction::Replace(Pane::boxed(pane))));
                             }
                         }
                     }
-- 
GitLab


From bc759b2f4074462010eee0649e29b4c60a4693ee Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Fri, 21 Mar 2025 10:19:32 +0100
Subject: [PATCH 06/24] Simplified PaneBehavior by removing "contains_pointer"
 method

---
 src/ui/app.rs                    | 16 ++++++++++------
 src/ui/panes.rs                  |  7 -------
 src/ui/panes/default.rs          |  4 ----
 src/ui/panes/messages_viewer.rs  |  8 ++------
 src/ui/panes/pid_drawing_tool.rs |  8 --------
 src/ui/panes/plot.rs             |  7 -------
 src/ui/panes/valve_control.rs    | 13 +++----------
 7 files changed, 15 insertions(+), 48 deletions(-)

diff --git a/src/ui/app.rs b/src/ui/app.rs
index 199d195..801b580 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -49,11 +49,7 @@ impl eframe::App for App {
         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);
+        let hovered_pane = self.behavior.tile_id_hovered;
         trace!("Hovered pane: {:?}", hovered_pane);
 
         // Capture any pane action generated by pane children
@@ -400,6 +396,7 @@ impl AppState {
 #[derive(Default)]
 pub struct AppBehavior {
     pub action: Option<(TileId, PaneAction)>,
+    pub tile_id_hovered: Option<TileId>,
 }
 
 impl Behavior<Pane> for AppBehavior {
@@ -409,10 +406,17 @@ impl Behavior<Pane> for AppBehavior {
         tile_id: TileId,
         pane: &mut Pane,
     ) -> egui_tiles::UiResponse {
+        let res = ui.scope(|ui| pane.ui(ui));
         let PaneResponse {
             action_called,
             drag_response,
-        } = pane.ui(ui);
+        } = res.inner;
+
+        // Check if the pointer is hovering over the pane
+        if res.response.contains_pointer() {
+            self.tile_id_hovered = Some(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((tile_id, action_called));
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index 1bec4df..9e0ea2f 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -29,9 +29,6 @@ pub trait PaneBehavior {
     /// Renders the UI of the pane.
     fn ui(&mut self, ui: &mut Ui) -> PaneResponse;
 
-    /// Whether the pane contains the pointer.
-    fn contains_pointer(&self) -> bool;
-
     /// Updates the pane state. This method is called before `ui` to allow the
     /// pane to update its state based on the messages received.
     fn update(&mut self, _messages: &[&TimedMessage]) {}
@@ -57,10 +54,6 @@ impl PaneBehavior for Pane {
         self.pane.ui(ui)
     }
 
-    fn contains_pointer(&self) -> bool {
-        self.pane.contains_pointer()
-    }
-
     fn update(&mut self, messages: &[&TimedMessage]) {
         self.pane.update(messages)
     }
diff --git a/src/ui/panes/default.rs b/src/ui/panes/default.rs
index 63eb9d7..e0419ca 100644
--- a/src/ui/panes/default.rs
+++ b/src/ui/panes/default.rs
@@ -60,10 +60,6 @@ impl PaneBehavior for DefaultPane {
         response
     }
 
-    fn contains_pointer(&self) -> bool {
-        self.contains_pointer
-    }
-
     fn update(&mut self, _messages: &[&TimedMessage]) {}
 
     fn get_message_subscriptions(&self) -> Box<dyn Iterator<Item = u32>> {
diff --git a/src/ui/panes/messages_viewer.rs b/src/ui/panes/messages_viewer.rs
index 628f2d1..3d85aef 100644
--- a/src/ui/panes/messages_viewer.rs
+++ b/src/ui/panes/messages_viewer.rs
@@ -1,4 +1,4 @@
-use egui::Label;
+use egui::{Label, Ui};
 use serde::{Deserialize, Serialize};
 
 use crate::ui::app::PaneResponse;
@@ -19,7 +19,7 @@ impl PartialEq for MessagesViewerPane {
 
 impl PaneBehavior for MessagesViewerPane {
     #[profiling::function]
-    fn ui(&mut self, ui: &mut egui::Ui) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
         let mut response = PaneResponse::default();
         let label = ui.add_sized(ui.available_size(), Label::new("This is a label"));
         self.contains_pointer = label.contains_pointer();
@@ -28,8 +28,4 @@ impl PaneBehavior for MessagesViewerPane {
         }
         response
     }
-
-    fn contains_pointer(&self) -> bool {
-        self.contains_pointer
-    }
 }
diff --git a/src/ui/panes/pid_drawing_tool.rs b/src/ui/panes/pid_drawing_tool.rs
index 1a7ec73..05160cc 100644
--- a/src/ui/panes/pid_drawing_tool.rs
+++ b/src/ui/panes/pid_drawing_tool.rs
@@ -52,8 +52,6 @@ pub struct PidPane {
     editable: bool,
     #[serde(skip)]
     is_subs_window_visible: bool,
-    #[serde(skip)]
-    contains_pointer: bool,
 }
 
 impl Default for PidPane {
@@ -67,7 +65,6 @@ impl Default for PidPane {
             action: None,
             editable: false,
             is_subs_window_visible: false,
-            contains_pointer: false,
         }
     }
 }
@@ -137,7 +134,6 @@ impl PaneBehavior for PidPane {
         }
 
         // Check if the user is draqging the pane
-        self.contains_pointer = response.contains_pointer();
         let ctrl_pressed = ui.input(|i| i.modifiers.ctrl);
         if response.dragged() && (ctrl_pressed || !self.editable) {
             pane_response.set_drag_started();
@@ -146,10 +142,6 @@ impl PaneBehavior for PidPane {
         pane_response
     }
 
-    fn contains_pointer(&self) -> bool {
-        self.contains_pointer
-    }
-
     fn update(&mut self, messages: &[&TimedMessage]) {
         if let Some(msg) = messages.last() {
             for element in &mut self.elements {
diff --git a/src/ui/panes/plot.rs b/src/ui/panes/plot.rs
index 0d67e3d..af83540 100644
--- a/src/ui/panes/plot.rs
+++ b/src/ui/panes/plot.rs
@@ -31,8 +31,6 @@ pub struct Plot2DPane {
     state_valid: bool,
     #[serde(skip)]
     settings_visible: bool,
-    #[serde(skip)]
-    contains_pointer: bool,
 }
 
 impl PartialEq for Plot2DPane {
@@ -145,7 +143,6 @@ impl PaneBehavior for Plot2DPane {
         }
 
         plot.show(ui, |plot_ui| {
-            self.contains_pointer = plot_ui.response().contains_pointer();
             if plot_ui.response().dragged() && ctrl_pressed {
                 response.set_drag_started();
             }
@@ -180,10 +177,6 @@ impl PaneBehavior for Plot2DPane {
         response
     }
 
-    fn contains_pointer(&self) -> bool {
-        self.contains_pointer
-    }
-
     #[profiling::function]
     fn update(&mut self, messages: &[&TimedMessage]) {
         if !self.state_valid {
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index f2e83cc..4fdf47d 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -1,3 +1,4 @@
+use egui::Ui;
 use serde::{Deserialize, Serialize};
 
 use crate::ui::app::PaneResponse;
@@ -7,18 +8,10 @@ use super::PaneBehavior;
 mod enums;
 
 #[derive(Clone, PartialEq, Default, Serialize, Deserialize, Debug)]
-pub struct ValveControlPane {
-    // Temporary Internal state
-    #[serde(skip)]
-    contains_pointer: bool,
-}
+pub struct ValveControlPane {}
 
 impl PaneBehavior for ValveControlPane {
-    fn ui(&mut self, ui: &mut egui::Ui) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
         todo!()
     }
-
-    fn contains_pointer(&self) -> bool {
-        self.contains_pointer
-    }
 }
-- 
GitLab


From 0092f152c5da4e06c2cb21034b85a06e35a7e2bd Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Fri, 21 Mar 2025 17:49:45 +0100
Subject: [PATCH 07/24] CHECKPOINT

---
 Cargo.toml                             |   2 +-
 icons/valve_control/dark/aperture.svg  |   7 +
 icons/valve_control/dark/timing.svg    |   6 +
 icons/valve_control/light/aperture.svg |   7 +
 icons/valve_control/light/timing.svg   |   6 +
 src/ui/panes/messages_viewer.rs        |  14 +-
 src/ui/panes/plot/source_window.rs     |   1 +
 src/ui/panes/valve_control.rs          | 316 ++++++++++++++++++++++++-
 src/ui/panes/valve_control/commands.rs | 163 +++++++++++++
 src/ui/panes/valve_control/enums.rs    |  68 ------
 src/ui/panes/valve_control/icons.rs    |  45 ++++
 src/ui/panes/valve_control/valves.rs   | 139 +++++++++++
 12 files changed, 688 insertions(+), 86 deletions(-)
 create mode 100644 icons/valve_control/dark/aperture.svg
 create mode 100644 icons/valve_control/dark/timing.svg
 create mode 100644 icons/valve_control/light/aperture.svg
 create mode 100644 icons/valve_control/light/timing.svg
 create mode 100644 src/ui/panes/valve_control/commands.rs
 delete mode 100644 src/ui/panes/valve_control/enums.rs
 create mode 100644 src/ui/panes/valve_control/icons.rs
 create mode 100644 src/ui/panes/valve_control/valves.rs

diff --git a/Cargo.toml b/Cargo.toml
index 8c2de1b..5698828 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ authors = [
     "Niccolò Betto <niccolo.betto@skywarder.eu>",
 ]
 edition = "2024"
-description = "Skyward Enhanced Ground Software"
+edescription = "Skyward Enhanced Ground Software"
 license = "MIT"
 
 [dependencies]
diff --git a/icons/valve_control/dark/aperture.svg b/icons/valve_control/dark/aperture.svg
new file mode 100644
index 0000000..75a1a4f
--- /dev/null
+++ b/icons/valve_control/dark/aperture.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 14 14" role="img" focusable="false" aria-hidden="true"
+    xmlns="http://www.w3.org/2000/svg">
+    <path
+        d="m 9.8075842,8.125795 c -0.047226,-0.0818 -0.1653177,-0.0818 -0.2125819,-2e-5 l -2.7091686,4.69002 c -0.047101,0.0815 0.011348,0.18406 0.1055111,0.18419 0.00288,0 0.00577,0 0.00866,0 1.7426489,0 3.3117022,-0.74298 4.4078492,-1.92938 0.03656,-0.0396 0.04318,-0.0983 0.01627,-0.14492 L 9.807592,8.125795 Z m -2.4888999,1.68471 -5.4097573,0.005 c -0.094226,8e-5 -0.1536307,0.10217 -0.1064545,0.18373 0.8246515,1.42592 2.2192034,2.4809 3.8722553,2.85359 0.052573,0.0119 0.1068068,-0.0117 0.1337538,-0.0583 l 1.6166069,-2.80007 c 0.047277,-0.0819 -0.011863,-0.18422 -0.1064042,-0.18411 z m 4.8812207,-5.80581 c -0.825356,-1.42976 -2.2234427,-2.48728 -3.8807595,-2.85911 -0.052561,-0.0118 -0.1067061,0.0117 -0.1336405,0.0584 l -1.6174875,2.80157 c -0.047252,0.0819 0.011813,0.18415 0.1063413,0.18412 l 5.4189662,-0.001 c 0.0942,-2e-5 0.153693,-0.10205 0.10658,-0.18365 z m 0.413678,1.12713 -3.2344588,0 c -0.094503,0 -0.1535677,0.10233 -0.1062909,0.18415 l 2.7089927,4.6888 c 0.04735,0.0819 0.165368,0.0815 0.212808,-3.1e-4 C 12.706741,9.120925 13,8.094715 13,7.000015 c 0,-0.62043 -0.09423,-1.2188 -0.269055,-1.7817 -0.01595,-0.0514 -0.06352,-0.0865 -0.117362,-0.0865 z M 7.0021135,1.000015 c -7.045e-4,0 -0.00142,0 -0.00213,0 -1.7423595,0 -3.311199,0.74275 -4.4073209,1.92881 -0.036558,0.0395 -0.043188,0.0983 -0.016254,0.14494 l 1.6162797,2.79947 c 0.047264,0.0819 0.1654436,0.0818 0.2126575,-9e-5 l 2.7023877,-4.6891 c 0.04695,-0.0815 -0.011498,-0.18401 -0.1056242,-0.18403 z m -5.6144011,7.87237 3.2356039,0 c 0.094503,0 0.1535552,-0.10231 0.106291,-0.18416 L 2.0185518,3.994515 c -0.047327,-0.082 -0.1653304,-0.0816 -0.2127959,3e-4 C 1.2933853,4.878505 1,5.904985 1,7.000015 c 0,0.62198 0.094717,1.22181 0.2703885,1.78596 0.01599,0.0514 0.063518,0.0864 0.1173239,0.0864 z" />
+</svg>
diff --git a/icons/valve_control/dark/timing.svg b/icons/valve_control/dark/timing.svg
new file mode 100644
index 0000000..e267bde
--- /dev/null
+++ b/icons/valve_control/dark/timing.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
+    <path
+        d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0780 4.0937 28.0234 4.0937 C 26.7812 4.0937 26.1718 4.8437 26.1718 6.0625 L 26.1718 15.1563 C 26.1718 16.1641 26.8514 16.9844 27.8827 16.9844 C 28.9140 16.9844 29.6171 16.1641 29.6171 15.1563 L 29.6171 8.1484 C 39.9296 8.9688 47.8983 17.5 47.8983 28 C 47.8983 39.0625 39.0390 47.9219 27.9999 47.9219 C 16.9374 47.9219 8.0546 39.0625 8.0780 28 C 8.1014 23.0781 9.8593 18.6016 12.7890 15.1563 C 13.5155 14.2422 13.5624 13.1406 12.7890 12.3203 C 12.0155 11.4766 10.7030 11.5469 9.8593 12.6016 C 6.2733 16.7734 4.0937 22.1641 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 31.7499 31.6094 C 33.6014 29.6875 33.2265 27.0625 30.9999 25.5156 L 18.6014 16.8672 C 17.4296 16.0469 16.2109 17.2656 17.0312 18.4375 L 25.6796 30.8359 C 27.2265 33.0625 29.8514 33.4609 31.7499 31.6094 Z" />
+</svg>
diff --git a/icons/valve_control/light/aperture.svg b/icons/valve_control/light/aperture.svg
new file mode 100644
index 0000000..7975283
--- /dev/null
+++ b/icons/valve_control/light/aperture.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#000000" width="800px" height="800px" viewBox="0 0 14 14" role="img" focusable="false" aria-hidden="true"
+    xmlns="http://www.w3.org/2000/svg">
+    <path
+        d="m 9.8075842,8.125795 c -0.047226,-0.0818 -0.1653177,-0.0818 -0.2125819,-2e-5 l -2.7091686,4.69002 c -0.047101,0.0815 0.011348,0.18406 0.1055111,0.18419 0.00288,0 0.00577,0 0.00866,0 1.7426489,0 3.3117022,-0.74298 4.4078492,-1.92938 0.03656,-0.0396 0.04318,-0.0983 0.01627,-0.14492 L 9.807592,8.125795 Z m -2.4888999,1.68471 -5.4097573,0.005 c -0.094226,8e-5 -0.1536307,0.10217 -0.1064545,0.18373 0.8246515,1.42592 2.2192034,2.4809 3.8722553,2.85359 0.052573,0.0119 0.1068068,-0.0117 0.1337538,-0.0583 l 1.6166069,-2.80007 c 0.047277,-0.0819 -0.011863,-0.18422 -0.1064042,-0.18411 z m 4.8812207,-5.80581 c -0.825356,-1.42976 -2.2234427,-2.48728 -3.8807595,-2.85911 -0.052561,-0.0118 -0.1067061,0.0117 -0.1336405,0.0584 l -1.6174875,2.80157 c -0.047252,0.0819 0.011813,0.18415 0.1063413,0.18412 l 5.4189662,-0.001 c 0.0942,-2e-5 0.153693,-0.10205 0.10658,-0.18365 z m 0.413678,1.12713 -3.2344588,0 c -0.094503,0 -0.1535677,0.10233 -0.1062909,0.18415 l 2.7089927,4.6888 c 0.04735,0.0819 0.165368,0.0815 0.212808,-3.1e-4 C 12.706741,9.120925 13,8.094715 13,7.000015 c 0,-0.62043 -0.09423,-1.2188 -0.269055,-1.7817 -0.01595,-0.0514 -0.06352,-0.0865 -0.117362,-0.0865 z M 7.0021135,1.000015 c -7.045e-4,0 -0.00142,0 -0.00213,0 -1.7423595,0 -3.311199,0.74275 -4.4073209,1.92881 -0.036558,0.0395 -0.043188,0.0983 -0.016254,0.14494 l 1.6162797,2.79947 c 0.047264,0.0819 0.1654436,0.0818 0.2126575,-9e-5 l 2.7023877,-4.6891 c 0.04695,-0.0815 -0.011498,-0.18401 -0.1056242,-0.18403 z m -5.6144011,7.87237 3.2356039,0 c 0.094503,0 0.1535552,-0.10231 0.106291,-0.18416 L 2.0185518,3.994515 c -0.047327,-0.082 -0.1653304,-0.0816 -0.2127959,3e-4 C 1.2933853,4.878505 1,5.904985 1,7.000015 c 0,0.62198 0.094717,1.22181 0.2703885,1.78596 0.01599,0.0514 0.063518,0.0864 0.1173239,0.0864 z" />
+</svg>
diff --git a/icons/valve_control/light/timing.svg b/icons/valve_control/light/timing.svg
new file mode 100644
index 0000000..dd2d1e1
--- /dev/null
+++ b/icons/valve_control/light/timing.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg fill="#000000" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
+    <path
+        d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0780 4.0937 28.0234 4.0937 C 26.7812 4.0937 26.1718 4.8437 26.1718 6.0625 L 26.1718 15.1563 C 26.1718 16.1641 26.8514 16.9844 27.8827 16.9844 C 28.9140 16.9844 29.6171 16.1641 29.6171 15.1563 L 29.6171 8.1484 C 39.9296 8.9688 47.8983 17.5 47.8983 28 C 47.8983 39.0625 39.0390 47.9219 27.9999 47.9219 C 16.9374 47.9219 8.0546 39.0625 8.0780 28 C 8.1014 23.0781 9.8593 18.6016 12.7890 15.1563 C 13.5155 14.2422 13.5624 13.1406 12.7890 12.3203 C 12.0155 11.4766 10.7030 11.5469 9.8593 12.6016 C 6.2733 16.7734 4.0937 22.1641 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 31.7499 31.6094 C 33.6014 29.6875 33.2265 27.0625 30.9999 25.5156 L 18.6014 16.8672 C 17.4296 16.0469 16.2109 17.2656 17.0312 18.4375 L 25.6796 30.8359 C 27.2265 33.0625 29.8514 33.4609 31.7499 31.6094 Z" />
+</svg>
diff --git a/src/ui/panes/messages_viewer.rs b/src/ui/panes/messages_viewer.rs
index 3d85aef..cfee6c6 100644
--- a/src/ui/panes/messages_viewer.rs
+++ b/src/ui/panes/messages_viewer.rs
@@ -5,24 +5,14 @@ use crate::ui::app::PaneResponse;
 
 use super::PaneBehavior;
 
-#[derive(Clone, Debug, Default, Serialize, Deserialize)]
-pub struct MessagesViewerPane {
-    #[serde(skip)]
-    contains_pointer: bool,
-}
-
-impl PartialEq for MessagesViewerPane {
-    fn eq(&self, _other: &Self) -> bool {
-        true
-    }
-}
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
+pub struct MessagesViewerPane;
 
 impl PaneBehavior for MessagesViewerPane {
     #[profiling::function]
     fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
         let mut response = PaneResponse::default();
         let label = ui.add_sized(ui.available_size(), Label::new("This is a label"));
-        self.contains_pointer = label.contains_pointer();
         if label.drag_started() {
             response.set_drag_started();
         }
diff --git a/src/ui/panes/plot/source_window.rs b/src/ui/panes/plot/source_window.rs
index a62d1f9..c309878 100644
--- a/src/ui/panes/plot/source_window.rs
+++ b/src/ui/panes/plot/source_window.rs
@@ -14,6 +14,7 @@ pub fn sources_window(ui: &mut egui::Ui, plot_settings: &mut PlotSettings) {
             egui::DragValue::new(&mut points_lifespan_sec)
                 .range(5..=1800)
                 .speed(1)
+                .update_while_editing(false)
                 .suffix(" seconds"),
         );
         res1.union(res2)
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 4fdf47d..1e781c9 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -1,17 +1,323 @@
-use egui::Ui;
+mod commands;
+mod icons;
+mod valves;
+
+use std::time::{Duration, Instant};
+
+use egui::{
+    Color32, DragValue, Frame, Label, Rect, RichText, Sense, Stroke, Ui, UiBuilder, Vec2, Widget,
+    vec2,
+};
+use egui_extras::{Size, StripBuilder};
+use itertools::Itertools;
 use serde::{Deserialize, Serialize};
+use skyward_mavlink::{
+    mavlink::MessageData,
+    orion::{ACK_TM_DATA, NACK_TM_DATA, WACK_TM_DATA},
+};
+use strum::IntoEnumIterator;
+use tracing::info;
 
-use crate::ui::app::PaneResponse;
+use crate::{
+    mavlink::{MavMessage, TimedMessage},
+    ui::app::PaneResponse,
+};
 
 use super::PaneBehavior;
 
-mod enums;
+use commands::CommandSM;
+use icons::Icon;
+use valves::{Valve, ValveStateManager};
+
+const DEFAULT_AUTO_REFRESH_RATE: Duration = Duration::from_secs(1);
 
 #[derive(Clone, PartialEq, Default, Serialize, Deserialize, Debug)]
-pub struct ValveControlPane {}
+pub struct ValveControlPane {
+    // INTERNAL
+    #[serde(skip)]
+    valves_state: ValveStateManager,
+
+    // VALVE COMMANDS LIST
+    #[serde(skip)]
+    commands: Vec<CommandSM>,
+
+    // REFRESH SETTINGS
+    auto_refresh: Option<Duration>,
+
+    #[serde(skip)]
+    manual_refresh: bool,
+
+    #[serde(skip)]
+    last_refresh: Option<Instant>,
+
+    // UI SETTINGS
+    #[serde(skip)]
+    is_settings_window_open: bool,
+}
 
 impl PaneBehavior for ValveControlPane {
     fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
-        todo!()
+        let mut pane_response = PaneResponse::default();
+
+        let res = ui
+            .scope_builder(UiBuilder::new().sense(Sense::click_and_drag()), |ui| {
+                self.pane_ui()(ui);
+                ui.allocate_space(ui.available_size());
+            })
+            .response;
+
+        // Show the menu when the user right-clicks the pane
+        res.context_menu(self.menu_ui());
+
+        // Check if the user started dragging the pane
+        if res.drag_started() {
+            pane_response.set_drag_started();
+        }
+
+        egui::Window::new("Settings")
+            .id(ui.auto_id_with("settings"))
+            .auto_sized()
+            .collapsible(true)
+            .movable(true)
+            .open(&mut self.is_settings_window_open)
+            .show(ui.ctx(), Self::window_ui(&mut self.auto_refresh));
+
+        pane_response
+    }
+
+    fn get_message_subscriptions(&self) -> Box<dyn Iterator<Item = u32>> {
+        let mut subscriptions = vec![];
+        if self.needs_refresh() {
+            // TODO
+            // subscriptions.push();
+        }
+
+        // Subscribe to ACK, NACK, WACK messages if any command is waiting for a response
+        if self.commands.iter().any(CommandSM::is_waiting_for_response) {
+            subscriptions.push(ACK_TM_DATA::ID);
+            subscriptions.push(NACK_TM_DATA::ID);
+            subscriptions.push(WACK_TM_DATA::ID);
+        }
+
+        Box::new(subscriptions.into_iter())
+    }
+
+    fn update(&mut self, messages: &[&TimedMessage]) {
+        if self.needs_refresh() {
+            // TODO
+        }
+
+        // Capture any ACK/NACK/WACK messages and update the valve state
+        for message in messages {
+            for cmd in self.commands.iter_mut() {
+                // intercept all ACK/NACK/WACK messages
+                cmd.capture_response(&message.message);
+                // If a response was captured, consume the command and update the valve state
+                if let Some((valve, parameter)) = cmd.consume_response() {
+                    self.valves_state.set_parameter_of(valve, parameter);
+                }
+            }
+
+            // Remove consumed commands
+            self.commands.retain(|cmd| !cmd.is_consumed());
+        }
+
+        self.reset_last_refresh();
+    }
+
+    fn drain_outgoing_messages(&mut self) -> Vec<MavMessage> {
+        let mut outgoing = vec![];
+
+        // Pack and send the next command
+        for cmd in self.commands.iter_mut() {
+            if let Some(message) = cmd.pack_and_wait() {
+                outgoing.push(message);
+            }
+        }
+
+        outgoing
+    }
+}
+
+// ┌────────────────────────┐
+// │       UI METHODS       │
+// └────────────────────────┘
+impl ValveControlPane {
+    fn pane_ui(&mut self) -> impl FnOnce(&mut Ui) {
+        |ui| {
+            let valve_chunks = Valve::iter().chunks(3);
+            StripBuilder::new(ui)
+                .sizes(Size::remainder(), 3)
+                .vertical(|mut strip| {
+                    for chunk in &valve_chunks {
+                        strip.strip(|builder| {
+                            builder.sizes(Size::remainder(), 3).horizontal(|mut strip| {
+                                for valve in chunk {
+                                    strip.cell(self.valve_frame_ui(valve));
+                                }
+                            });
+                        });
+                    }
+                });
+        }
+    }
+
+    fn menu_ui(&mut self) -> impl FnOnce(&mut Ui) {
+        |ui| {
+            if ui.button("Refresh now").clicked() {
+                self.manual_refresh = true;
+                ui.close_menu();
+            }
+            if ui.button("Settings").clicked() {
+                self.is_settings_window_open = true;
+                ui.close_menu();
+            }
+        }
+    }
+
+    fn window_ui(auto_refresh_setting: &mut Option<Duration>) -> impl FnOnce(&mut Ui) {
+        |ui| {
+            // Display auto refresh setting
+            let mut auto_refresh = auto_refresh_setting.is_some();
+            ui.horizontal(|ui| {
+                ui.checkbox(&mut auto_refresh, "Auto Refresh");
+                if auto_refresh {
+                    let auto_refresh_duration =
+                        auto_refresh_setting.get_or_insert(DEFAULT_AUTO_REFRESH_RATE);
+                    let mut auto_refresh_rate = 1. / auto_refresh_duration.as_secs_f32();
+                    DragValue::new(&mut auto_refresh_rate)
+                        .speed(0.1)
+                        .range(0.1..=10.0)
+                        .fixed_decimals(1)
+                        .update_while_editing(false)
+                        .suffix(" Hz")
+                        .ui(ui);
+                    *auto_refresh_duration = Duration::from_secs_f32(1. / auto_refresh_rate);
+                } else {
+                    *auto_refresh_setting = None;
+                }
+            });
+        }
+    }
+
+    fn valve_frame_ui(&self, valve: Valve) -> impl FnOnce(&mut Ui) {
+        move |ui| {
+            let valve_str = valve.to_string();
+            let timing = self.valves_state.get_timing_for(valve);
+            let aperture = self.valves_state.get_aperture_for(valve);
+
+            let timing_str = match timing {
+                valves::ParameterValue::Valid(value) => {
+                    format!("{} [ms]", value)
+                }
+                valves::ParameterValue::Missing => "N/A".to_owned(),
+                valves::ParameterValue::Invalid(err_id) => {
+                    format!("ERROR: {}", err_id)
+                }
+            };
+            let aperture_str = match aperture {
+                valves::ParameterValue::Valid(value) => {
+                    format!("{}", value)
+                }
+                valves::ParameterValue::Missing => "N/A".to_owned(),
+                valves::ParameterValue::Invalid(err_id) => {
+                    format!("ERROR: {}", err_id)
+                }
+            };
+
+            let inside_frame = |ui: &mut Ui| {
+                let response = ui.response();
+                let visuals = ui.style().interact(&response);
+                let text_color = visuals.text_color();
+                let icon_size = Vec2::splat(17.);
+
+                StripBuilder::new(ui)
+                    .size(Size::exact(20.))
+                    .size(Size::exact(20.))
+                    .vertical(|mut strip| {
+                        strip.cell(|ui| {
+                            Label::new(
+                                RichText::new(valve_str.to_ascii_uppercase())
+                                    .color(text_color)
+                                    .size(16.0),
+                            )
+                            .selectable(false)
+                            .ui(ui);
+                        });
+                        strip.cell(|ui| {
+                            ui.vertical(|ui| {
+                                ui.horizontal(|ui| {
+                                    let rect = Rect::from_min_size(ui.cursor().min, icon_size);
+                                    Icon::Timing.paint(ui, rect);
+                                    ui.allocate_rect(rect, Sense::hover());
+                                    Label::new(format!("Timing: {}", timing_str))
+                                        .selectable(false)
+                                        .ui(ui);
+                                });
+                                ui.horizontal(|ui| {
+                                    let rect = Rect::from_min_size(ui.cursor().min, icon_size);
+                                    Icon::Aperture.paint(ui, rect);
+                                    ui.allocate_rect(rect, Sense::hover());
+                                    Label::new(format!("Aperture: {}", aperture_str))
+                                        .selectable(false)
+                                        .ui(ui);
+                                });
+                            });
+                        });
+                    });
+            };
+
+            ui.scope_builder(
+                UiBuilder::new()
+                    .id_salt("valve_".to_owned() + &valve_str)
+                    .sense(Sense::click()),
+                |ui| {
+                    ui.set_width(200.);
+                    ui.set_height(60.);
+                    let response = ui.response();
+                    let visuals = ui.style().interact(&response);
+
+                    let fill_color = if response.hovered() {
+                        visuals.bg_fill
+                    } else {
+                        visuals.bg_fill.gamma_multiply(0.3)
+                    };
+
+                    Frame::canvas(ui.style())
+                        .fill(fill_color)
+                        .stroke(Stroke::NONE)
+                        .inner_margin(ui.spacing().menu_margin)
+                        .show(ui, inside_frame);
+
+                    if response.clicked() {
+                        info!("Clicked!");
+                    }
+                },
+            );
+        }
+    }
+}
+
+// ┌───────────────────────────┐
+// │       UTILS METHODS       │
+// └───────────────────────────┘
+impl ValveControlPane {
+    fn needs_refresh(&self) -> bool {
+        // manual trigger of refresh
+        let manual_triggered = self.manual_refresh;
+        // automatic trigger of refresh
+        let auto_triggered = if let Some(auto_refresh) = self.auto_refresh {
+            self.last_refresh
+                .is_none_or(|last| last.elapsed() >= auto_refresh)
+        } else {
+            false
+        };
+
+        manual_triggered || auto_triggered
+    }
+
+    fn reset_last_refresh(&mut self) {
+        self.last_refresh = Some(Instant::now());
+        self.manual_refresh = false;
     }
 }
diff --git a/src/ui/panes/valve_control/commands.rs b/src/ui/panes/valve_control/commands.rs
new file mode 100644
index 0000000..8593fd9
--- /dev/null
+++ b/src/ui/panes/valve_control/commands.rs
@@ -0,0 +1,163 @@
+use crate::mavlink::{
+    ACK_TM_DATA, MavMessage, MessageData, NACK_TM_DATA, SET_ATOMIC_VALVE_TIMING_TC_DATA,
+    SET_VALVE_MAXIMUM_APERTURE_TC_DATA, WACK_TM_DATA,
+};
+
+use super::valves::{ParameterValue, Valve, ValveParameter};
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum CommandSM {
+    Request(Command),
+    WaitingForResponse(Command),
+    Response((Valve, ValveParameter)),
+    Consumed,
+}
+
+impl CommandSM {
+    pub fn pack_and_wait(&mut self) -> Option<MavMessage> {
+        match self {
+            CommandSM::Request(command) => {
+                let message = MavMessage::from(command.clone());
+                *self = CommandSM::WaitingForResponse(command.clone());
+                Some(message)
+            }
+            _ => None,
+        }
+    }
+
+    pub fn capture_response(&mut self, message: &MavMessage) {
+        if let CommandSM::WaitingForResponse(Command { kind, valve }) = self {
+            let id = kind.message_id() as u8;
+            match message {
+                MavMessage::ACK_TM(ACK_TM_DATA { recv_msgid, .. }) if *recv_msgid == id => {
+                    *self = CommandSM::Response((*valve, kind.to_valid_parameter()));
+                }
+                MavMessage::NACK_TM(NACK_TM_DATA {
+                    err_id, recv_msgid, ..
+                }) if *recv_msgid == id => {
+                    *self = CommandSM::Response((*valve, kind.to_invalid_parameter(*err_id)));
+                }
+                MavMessage::WACK_TM(WACK_TM_DATA {
+                    err_id, recv_msgid, ..
+                }) if *recv_msgid == id => {
+                    *self = CommandSM::Response((*valve, kind.to_invalid_parameter(*err_id)));
+                }
+                _ => {}
+            }
+        }
+    }
+
+    pub fn consume_response(&mut self) -> Option<(Valve, ValveParameter)> {
+        match self {
+            CommandSM::Response((valve, parameter)) => {
+                let res = Some((*valve, parameter.clone()));
+                *self = CommandSM::Consumed;
+                res
+            }
+            _ => None,
+        }
+    }
+
+    pub fn is_waiting_for_response(&self) -> bool {
+        matches!(self, CommandSM::WaitingForResponse(_))
+    }
+
+    pub fn is_consumed(&self) -> bool {
+        matches!(self, CommandSM::Consumed)
+    }
+}
+
+impl From<Command> for CommandSM {
+    fn from(value: Command) -> Self {
+        CommandSM::Request(value)
+    }
+}
+
+trait ControllableValves {
+    fn set_atomic_valve_timing(self, timing: u32) -> Command;
+    fn set_valve_maximum_aperture(self, aperture: f32) -> Command;
+}
+
+impl ControllableValves for Valve {
+    fn set_atomic_valve_timing(self, timing: u32) -> Command {
+        Command {
+            kind: CommandKind::SetAtomicValveTiming(timing),
+            valve: self,
+        }
+    }
+
+    fn set_valve_maximum_aperture(self, aperture: f32) -> Command {
+        Command {
+            kind: CommandKind::SetValveMaximumAperture(aperture),
+            valve: self,
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct Command {
+    kind: CommandKind,
+    valve: Valve,
+}
+
+impl From<Command> for MavMessage {
+    fn from(value: Command) -> Self {
+        match value.kind {
+            CommandKind::SetAtomicValveTiming(timing) => {
+                MavMessage::SET_ATOMIC_VALVE_TIMING_TC(SET_ATOMIC_VALVE_TIMING_TC_DATA {
+                    servo_id: value.valve.into(),
+                    maximum_timing: timing,
+                })
+            }
+            CommandKind::SetValveMaximumAperture(aperture) => {
+                MavMessage::SET_VALVE_MAXIMUM_APERTURE_TC(SET_VALVE_MAXIMUM_APERTURE_TC_DATA {
+                    servo_id: value.valve.into(),
+                    maximum_aperture: aperture,
+                })
+            }
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum CommandKind {
+    SetAtomicValveTiming(u32),
+    SetValveMaximumAperture(f32),
+}
+
+impl CommandKind {
+    fn message_id(&self) -> u32 {
+        match self {
+            CommandKind::SetAtomicValveTiming(_) => SET_ATOMIC_VALVE_TIMING_TC_DATA::ID,
+            CommandKind::SetValveMaximumAperture(_) => SET_VALVE_MAXIMUM_APERTURE_TC_DATA::ID,
+        }
+    }
+
+    fn to_valid_parameter(&self) -> ValveParameter {
+        (*self).into()
+    }
+
+    fn to_invalid_parameter(&self, error: u16) -> ValveParameter {
+        match self {
+            CommandKind::SetAtomicValveTiming(_) => {
+                ValveParameter::AtomicValveTiming(ParameterValue::Invalid(error))
+            }
+            CommandKind::SetValveMaximumAperture(_) => {
+                ValveParameter::ValveMaximumAperture(ParameterValue::Invalid(error))
+            }
+        }
+    }
+}
+
+impl From<CommandKind> for ValveParameter {
+    fn from(value: CommandKind) -> Self {
+        match value {
+            CommandKind::SetAtomicValveTiming(timing) => {
+                ValveParameter::AtomicValveTiming(ParameterValue::Valid(timing))
+            }
+            CommandKind::SetValveMaximumAperture(aperture) => {
+                ValveParameter::ValveMaximumAperture(ParameterValue::Valid(aperture))
+            }
+        }
+    }
+}
diff --git a/src/ui/panes/valve_control/enums.rs b/src/ui/panes/valve_control/enums.rs
deleted file mode 100644
index a1bf8fc..0000000
--- a/src/ui/panes/valve_control/enums.rs
+++ /dev/null
@@ -1,68 +0,0 @@
-use std::fmt::Display;
-
-use strum_macros::EnumIter;
-
-use crate::mavlink::{
-    MessageData, SET_ATOMIC_VALVE_TIMING_TC_DATA, SET_VALVE_MAXIMUM_APERTURE_TC_DATA, Servoslist,
-};
-
-#[allow(non_camel_case_types)]
-#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter)]
-pub enum Valve {
-    OxFilling,
-    OxRelease,
-    OxVenting,
-    N2Filling,
-    N2Release,
-    N2Quenching,
-    N23Way,
-    Main,
-    Nitrogen,
-}
-
-impl From<Valve> for Servoslist {
-    fn from(valve: Valve) -> Servoslist {
-        match valve {
-            Valve::OxFilling => Servoslist::OX_FILLING_VALVE,
-            Valve::OxRelease => Servoslist::OX_RELEASE_VALVE,
-            Valve::OxVenting => Servoslist::OX_VENTING_VALVE,
-            Valve::N2Filling => Servoslist::N2_FILLING_VALVE,
-            Valve::N2Release => Servoslist::N2_RELEASE_VALVE,
-            Valve::N2Quenching => Servoslist::N2_QUENCHING_VALVE,
-            Valve::N23Way => Servoslist::N2_3WAY_VALVE,
-            Valve::Main => Servoslist::MAIN_VALVE,
-            Valve::Nitrogen => Servoslist::NITROGEN_VALVE,
-        }
-    }
-}
-
-impl Display for Valve {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Valve::OxFilling => write!(f, "Oxidizer Filling"),
-            Valve::OxRelease => write!(f, "Oxidizer Release"),
-            Valve::OxVenting => write!(f, "Oxidizer Venting"),
-            Valve::N2Filling => write!(f, "Nitrogen Filling"),
-            Valve::N2Release => write!(f, "Nitrogen Release"),
-            Valve::N2Quenching => write!(f, "Nitrogen Quenching"),
-            Valve::N23Way => write!(f, "Nitrogen 3-Way"),
-            Valve::Main => write!(f, "Main"),
-            Valve::Nitrogen => write!(f, "Nitrogen"),
-        }
-    }
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter)]
-pub enum ValveCommands {
-    AtomicValveTiming,
-    ValveMaximumAperture,
-}
-
-impl From<ValveCommands> for u32 {
-    fn from(command: ValveCommands) -> u32 {
-        match command {
-            ValveCommands::AtomicValveTiming => SET_ATOMIC_VALVE_TIMING_TC_DATA::ID,
-            ValveCommands::ValveMaximumAperture => SET_VALVE_MAXIMUM_APERTURE_TC_DATA::ID,
-        }
-    }
-}
diff --git a/src/ui/panes/valve_control/icons.rs b/src/ui/panes/valve_control/icons.rs
new file mode 100644
index 0000000..064fee5
--- /dev/null
+++ b/src/ui/panes/valve_control/icons.rs
@@ -0,0 +1,45 @@
+use egui::{ImageSource, Rect, Theme, Ui};
+
+#[derive(Debug, Clone, Copy)]
+pub enum Icon {
+    Aperture,
+    Timing,
+}
+
+impl Icon {
+    fn get_image(&self, theme: Theme) -> ImageSource {
+        match (&self, theme) {
+            (Icon::Aperture, Theme::Light) => {
+                egui::include_image!(concat!(
+                    env!("CARGO_MANIFEST_DIR"),
+                    "/icons/valve_control/light/aperture.svg"
+                ))
+            }
+            (Icon::Aperture, Theme::Dark) => {
+                egui::include_image!(concat!(
+                    env!("CARGO_MANIFEST_DIR"),
+                    "/icons/valve_control/dark/aperture.svg"
+                ))
+            }
+            (Icon::Timing, Theme::Light) => {
+                egui::include_image!(concat!(
+                    env!("CARGO_MANIFEST_DIR"),
+                    "/icons/valve_control/light/timing.svg"
+                ))
+            }
+            (Icon::Timing, Theme::Dark) => {
+                egui::include_image!(concat!(
+                    env!("CARGO_MANIFEST_DIR"),
+                    "/icons/valve_control/dark/timing.svg"
+                ))
+            }
+        }
+    }
+}
+
+impl Icon {
+    pub fn paint(&mut self, ui: &mut Ui, image_rect: Rect) {
+        let theme = ui.ctx().theme();
+        egui::Image::new(self.get_image(theme)).paint_at(ui, image_rect);
+    }
+}
diff --git a/src/ui/panes/valve_control/valves.rs b/src/ui/panes/valve_control/valves.rs
new file mode 100644
index 0000000..9580dc1
--- /dev/null
+++ b/src/ui/panes/valve_control/valves.rs
@@ -0,0 +1,139 @@
+//! Valve Control Pane
+//!
+//! NOTE: We assume that no more than one entity will sent messages to control valves at a time.
+
+use std::fmt::Display;
+
+use strum::IntoEnumIterator;
+use strum_macros::EnumIter;
+
+use crate::{error::ErrInstrument, mavlink::Servoslist};
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct ValveStateManager {
+    settings: Vec<(Valve, ValveParameter)>,
+}
+
+impl Default for ValveStateManager {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl ValveStateManager {
+    pub fn new() -> Self {
+        let settings = Valve::iter()
+            .flat_map(|valve| ValveParameter::iter().map(move |parameter| (valve, parameter)))
+            .collect();
+        Self { settings }
+    }
+
+    pub fn set_parameter_of(&mut self, valve: Valve, parameter: ValveParameter) {
+        let (_, par) = self
+            .settings
+            .iter_mut()
+            .find(|(v, _)| *v == valve)
+            .log_unwrap();
+        *par = parameter;
+    }
+
+    pub fn get_timing_for(&self, valve: Valve) -> ParameterValue<u32, u16> {
+        for (_, par) in self.settings.iter().filter(|(v, _)| *v == valve) {
+            match par {
+                ValveParameter::AtomicValveTiming(parameter_value) => {
+                    return parameter_value.clone();
+                }
+                _ => continue,
+            };
+        }
+        unreachable!()
+    }
+
+    pub fn get_aperture_for(&self, valve: Valve) -> ParameterValue<f32, u16> {
+        for (_, par) in self.settings.iter().filter(|(v, _)| *v == valve) {
+            match par {
+                ValveParameter::ValveMaximumAperture(parameter_value) => {
+                    return parameter_value.clone();
+                }
+                _ => continue,
+            };
+        }
+        unreachable!()
+    }
+}
+
+#[allow(non_camel_case_types)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter)]
+pub enum Valve {
+    OxFilling,
+    OxRelease,
+    OxVenting,
+    N2Filling,
+    N2Release,
+    N2Quenching,
+    N23Way,
+    Main,
+    Nitrogen,
+}
+
+impl From<Valve> for Servoslist {
+    fn from(valve: Valve) -> Servoslist {
+        match valve {
+            Valve::OxFilling => Servoslist::OX_FILLING_VALVE,
+            Valve::OxRelease => Servoslist::OX_RELEASE_VALVE,
+            Valve::OxVenting => Servoslist::OX_VENTING_VALVE,
+            Valve::N2Filling => Servoslist::N2_FILLING_VALVE,
+            Valve::N2Release => Servoslist::N2_RELEASE_VALVE,
+            Valve::N2Quenching => Servoslist::N2_QUENCHING_VALVE,
+            Valve::N23Way => Servoslist::N2_3WAY_VALVE,
+            Valve::Main => Servoslist::MAIN_VALVE,
+            Valve::Nitrogen => Servoslist::NITROGEN_VALVE,
+        }
+    }
+}
+
+impl From<Valve> for u8 {
+    fn from(valve: Valve) -> u8 {
+        Servoslist::from(valve) as u8
+    }
+}
+
+impl Display for Valve {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Valve::OxFilling => write!(f, "Oxidizer Filling"),
+            Valve::OxRelease => write!(f, "Oxidizer Release"),
+            Valve::OxVenting => write!(f, "Oxidizer Venting"),
+            Valve::N2Filling => write!(f, "Nitrogen Filling"),
+            Valve::N2Release => write!(f, "Nitrogen Release"),
+            Valve::N2Quenching => write!(f, "Nitrogen Quenching"),
+            Valve::N23Way => write!(f, "Nitrogen 3-Way"),
+            Valve::Main => write!(f, "Main"),
+            Valve::Nitrogen => write!(f, "Nitrogen"),
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, EnumIter)]
+pub enum ValveParameter {
+    AtomicValveTiming(ParameterValue<u32, u16>),
+    ValveMaximumAperture(ParameterValue<f32, u16>),
+}
+
+#[derive(Clone, Debug, PartialEq, Default)]
+pub enum ParameterValue<T, E> {
+    Valid(T), // T is the valid parameter value
+    #[default]
+    Missing, // The parameter is missing
+    Invalid(E), // E is the reason why the parameter is invalid
+}
+
+impl<T: Display, E: Display> Display for ParameterValue<T, E> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ParameterValue::Valid(value) => write!(f, "{}", value),
+            ParameterValue::Missing => write!(f, "MISSING"),
+            ParameterValue::Invalid(error) => write!(f, "INVALID: {}", error),
+        }
+    }
+}
-- 
GitLab


From 4be15a73d6862ab6839e44cc81fee0f536edfa15 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Fri, 21 Mar 2025 18:37:37 +0100
Subject: [PATCH 08/24] CHECKPOINT

---
 icons/valve_control/dark/aperture.svg  |  2 +-
 icons/valve_control/dark/timing.svg    |  2 +-
 icons/valve_control/light/aperture.svg |  2 +-
 icons/valve_control/light/timing.svg   |  2 +-
 src/ui/panes/valve_control.rs          | 34 ++++++++++++--------------
 5 files changed, 19 insertions(+), 23 deletions(-)

diff --git a/icons/valve_control/dark/aperture.svg b/icons/valve_control/dark/aperture.svg
index 75a1a4f..55195e2 100644
--- a/icons/valve_control/dark/aperture.svg
+++ b/icons/valve_control/dark/aperture.svg
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
-<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 14 14" role="img" focusable="false" aria-hidden="true"
+<svg fill="#8c8c8c" width="800px" height="800px" viewBox="0 0 14 14" role="img" focusable="false" aria-hidden="true"
     xmlns="http://www.w3.org/2000/svg">
     <path
         d="m 9.8075842,8.125795 c -0.047226,-0.0818 -0.1653177,-0.0818 -0.2125819,-2e-5 l -2.7091686,4.69002 c -0.047101,0.0815 0.011348,0.18406 0.1055111,0.18419 0.00288,0 0.00577,0 0.00866,0 1.7426489,0 3.3117022,-0.74298 4.4078492,-1.92938 0.03656,-0.0396 0.04318,-0.0983 0.01627,-0.14492 L 9.807592,8.125795 Z m -2.4888999,1.68471 -5.4097573,0.005 c -0.094226,8e-5 -0.1536307,0.10217 -0.1064545,0.18373 0.8246515,1.42592 2.2192034,2.4809 3.8722553,2.85359 0.052573,0.0119 0.1068068,-0.0117 0.1337538,-0.0583 l 1.6166069,-2.80007 c 0.047277,-0.0819 -0.011863,-0.18422 -0.1064042,-0.18411 z m 4.8812207,-5.80581 c -0.825356,-1.42976 -2.2234427,-2.48728 -3.8807595,-2.85911 -0.052561,-0.0118 -0.1067061,0.0117 -0.1336405,0.0584 l -1.6174875,2.80157 c -0.047252,0.0819 0.011813,0.18415 0.1063413,0.18412 l 5.4189662,-0.001 c 0.0942,-2e-5 0.153693,-0.10205 0.10658,-0.18365 z m 0.413678,1.12713 -3.2344588,0 c -0.094503,0 -0.1535677,0.10233 -0.1062909,0.18415 l 2.7089927,4.6888 c 0.04735,0.0819 0.165368,0.0815 0.212808,-3.1e-4 C 12.706741,9.120925 13,8.094715 13,7.000015 c 0,-0.62043 -0.09423,-1.2188 -0.269055,-1.7817 -0.01595,-0.0514 -0.06352,-0.0865 -0.117362,-0.0865 z M 7.0021135,1.000015 c -7.045e-4,0 -0.00142,0 -0.00213,0 -1.7423595,0 -3.311199,0.74275 -4.4073209,1.92881 -0.036558,0.0395 -0.043188,0.0983 -0.016254,0.14494 l 1.6162797,2.79947 c 0.047264,0.0819 0.1654436,0.0818 0.2126575,-9e-5 l 2.7023877,-4.6891 c 0.04695,-0.0815 -0.011498,-0.18401 -0.1056242,-0.18403 z m -5.6144011,7.87237 3.2356039,0 c 0.094503,0 0.1535552,-0.10231 0.106291,-0.18416 L 2.0185518,3.994515 c -0.047327,-0.082 -0.1653304,-0.0816 -0.2127959,3e-4 C 1.2933853,4.878505 1,5.904985 1,7.000015 c 0,0.62198 0.094717,1.22181 0.2703885,1.78596 0.01599,0.0514 0.063518,0.0864 0.1173239,0.0864 z" />
diff --git a/icons/valve_control/dark/timing.svg b/icons/valve_control/dark/timing.svg
index e267bde..a9d7e36 100644
--- a/icons/valve_control/dark/timing.svg
+++ b/icons/valve_control/dark/timing.svg
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
-<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
+<svg fill="#8c8c8c" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
     <path
         d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0780 4.0937 28.0234 4.0937 C 26.7812 4.0937 26.1718 4.8437 26.1718 6.0625 L 26.1718 15.1563 C 26.1718 16.1641 26.8514 16.9844 27.8827 16.9844 C 28.9140 16.9844 29.6171 16.1641 29.6171 15.1563 L 29.6171 8.1484 C 39.9296 8.9688 47.8983 17.5 47.8983 28 C 47.8983 39.0625 39.0390 47.9219 27.9999 47.9219 C 16.9374 47.9219 8.0546 39.0625 8.0780 28 C 8.1014 23.0781 9.8593 18.6016 12.7890 15.1563 C 13.5155 14.2422 13.5624 13.1406 12.7890 12.3203 C 12.0155 11.4766 10.7030 11.5469 9.8593 12.6016 C 6.2733 16.7734 4.0937 22.1641 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 31.7499 31.6094 C 33.6014 29.6875 33.2265 27.0625 30.9999 25.5156 L 18.6014 16.8672 C 17.4296 16.0469 16.2109 17.2656 17.0312 18.4375 L 25.6796 30.8359 C 27.2265 33.0625 29.8514 33.4609 31.7499 31.6094 Z" />
 </svg>
diff --git a/icons/valve_control/light/aperture.svg b/icons/valve_control/light/aperture.svg
index 7975283..df46696 100644
--- a/icons/valve_control/light/aperture.svg
+++ b/icons/valve_control/light/aperture.svg
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
-<svg fill="#000000" width="800px" height="800px" viewBox="0 0 14 14" role="img" focusable="false" aria-hidden="true"
+<svg fill="#505050" width="800px" height="800px" viewBox="0 0 14 14" role="img" focusable="false" aria-hidden="true"
     xmlns="http://www.w3.org/2000/svg">
     <path
         d="m 9.8075842,8.125795 c -0.047226,-0.0818 -0.1653177,-0.0818 -0.2125819,-2e-5 l -2.7091686,4.69002 c -0.047101,0.0815 0.011348,0.18406 0.1055111,0.18419 0.00288,0 0.00577,0 0.00866,0 1.7426489,0 3.3117022,-0.74298 4.4078492,-1.92938 0.03656,-0.0396 0.04318,-0.0983 0.01627,-0.14492 L 9.807592,8.125795 Z m -2.4888999,1.68471 -5.4097573,0.005 c -0.094226,8e-5 -0.1536307,0.10217 -0.1064545,0.18373 0.8246515,1.42592 2.2192034,2.4809 3.8722553,2.85359 0.052573,0.0119 0.1068068,-0.0117 0.1337538,-0.0583 l 1.6166069,-2.80007 c 0.047277,-0.0819 -0.011863,-0.18422 -0.1064042,-0.18411 z m 4.8812207,-5.80581 c -0.825356,-1.42976 -2.2234427,-2.48728 -3.8807595,-2.85911 -0.052561,-0.0118 -0.1067061,0.0117 -0.1336405,0.0584 l -1.6174875,2.80157 c -0.047252,0.0819 0.011813,0.18415 0.1063413,0.18412 l 5.4189662,-0.001 c 0.0942,-2e-5 0.153693,-0.10205 0.10658,-0.18365 z m 0.413678,1.12713 -3.2344588,0 c -0.094503,0 -0.1535677,0.10233 -0.1062909,0.18415 l 2.7089927,4.6888 c 0.04735,0.0819 0.165368,0.0815 0.212808,-3.1e-4 C 12.706741,9.120925 13,8.094715 13,7.000015 c 0,-0.62043 -0.09423,-1.2188 -0.269055,-1.7817 -0.01595,-0.0514 -0.06352,-0.0865 -0.117362,-0.0865 z M 7.0021135,1.000015 c -7.045e-4,0 -0.00142,0 -0.00213,0 -1.7423595,0 -3.311199,0.74275 -4.4073209,1.92881 -0.036558,0.0395 -0.043188,0.0983 -0.016254,0.14494 l 1.6162797,2.79947 c 0.047264,0.0819 0.1654436,0.0818 0.2126575,-9e-5 l 2.7023877,-4.6891 c 0.04695,-0.0815 -0.011498,-0.18401 -0.1056242,-0.18403 z m -5.6144011,7.87237 3.2356039,0 c 0.094503,0 0.1535552,-0.10231 0.106291,-0.18416 L 2.0185518,3.994515 c -0.047327,-0.082 -0.1653304,-0.0816 -0.2127959,3e-4 C 1.2933853,4.878505 1,5.904985 1,7.000015 c 0,0.62198 0.094717,1.22181 0.2703885,1.78596 0.01599,0.0514 0.063518,0.0864 0.1173239,0.0864 z" />
diff --git a/icons/valve_control/light/timing.svg b/icons/valve_control/light/timing.svg
index dd2d1e1..49bcce6 100644
--- a/icons/valve_control/light/timing.svg
+++ b/icons/valve_control/light/timing.svg
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
-<svg fill="#000000" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
+<svg fill="#505050" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
     <path
         d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0780 4.0937 28.0234 4.0937 C 26.7812 4.0937 26.1718 4.8437 26.1718 6.0625 L 26.1718 15.1563 C 26.1718 16.1641 26.8514 16.9844 27.8827 16.9844 C 28.9140 16.9844 29.6171 16.1641 29.6171 15.1563 L 29.6171 8.1484 C 39.9296 8.9688 47.8983 17.5 47.8983 28 C 47.8983 39.0625 39.0390 47.9219 27.9999 47.9219 C 16.9374 47.9219 8.0546 39.0625 8.0780 28 C 8.1014 23.0781 9.8593 18.6016 12.7890 15.1563 C 13.5155 14.2422 13.5624 13.1406 12.7890 12.3203 C 12.0155 11.4766 10.7030 11.5469 9.8593 12.6016 C 6.2733 16.7734 4.0937 22.1641 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 31.7499 31.6094 C 33.6014 29.6875 33.2265 27.0625 30.9999 25.5156 L 18.6014 16.8672 C 17.4296 16.0469 16.2109 17.2656 17.0312 18.4375 L 25.6796 30.8359 C 27.2265 33.0625 29.8514 33.4609 31.7499 31.6094 Z" />
 </svg>
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 1e781c9..e42c201 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -5,8 +5,8 @@ mod valves;
 use std::time::{Duration, Instant};
 
 use egui::{
-    Color32, DragValue, Frame, Label, Rect, RichText, Sense, Stroke, Ui, UiBuilder, Vec2, Widget,
-    vec2,
+    Color32, DragValue, Frame, Grid, Label, Layout, Rect, RichText, Sense, Stroke, Ui, UiBuilder,
+    Vec2, Widget, vec2,
 };
 use egui_extras::{Size, StripBuilder};
 use itertools::Itertools;
@@ -16,7 +16,7 @@ use skyward_mavlink::{
     orion::{ACK_TM_DATA, NACK_TM_DATA, WACK_TM_DATA},
 };
 use strum::IntoEnumIterator;
-use tracing::info;
+use tracing::{info, warn};
 
 use crate::{
     mavlink::{MavMessage, TimedMessage},
@@ -146,17 +146,15 @@ impl ValveControlPane {
     fn pane_ui(&mut self) -> impl FnOnce(&mut Ui) {
         |ui| {
             let valve_chunks = Valve::iter().chunks(3);
-            StripBuilder::new(ui)
-                .sizes(Size::remainder(), 3)
-                .vertical(|mut strip| {
+            Grid::new("valves_grid")
+                .num_columns(3)
+                .spacing(Vec2::splat(5.))
+                .show(ui, |ui| {
                     for chunk in &valve_chunks {
-                        strip.strip(|builder| {
-                            builder.sizes(Size::remainder(), 3).horizontal(|mut strip| {
-                                for valve in chunk {
-                                    strip.cell(self.valve_frame_ui(valve));
-                                }
-                            });
-                        });
+                        for valve in chunk {
+                            ui.scope(self.valve_frame_ui(valve));
+                        }
+                        ui.end_row();
                     }
                 });
         }
@@ -226,14 +224,11 @@ impl ValveControlPane {
             };
 
             let inside_frame = |ui: &mut Ui| {
-                let response = ui.response();
-                let visuals = ui.style().interact(&response);
-                let text_color = visuals.text_color();
+                let text_color = ui.visuals().text_color();
                 let icon_size = Vec2::splat(17.);
 
                 StripBuilder::new(ui)
-                    .size(Size::exact(20.))
-                    .size(Size::exact(20.))
+                    .sizes(Size::exact(20.), 2)
                     .vertical(|mut strip| {
                         strip.cell(|ui| {
                             Label::new(
@@ -273,7 +268,7 @@ impl ValveControlPane {
                     .sense(Sense::click()),
                 |ui| {
                     ui.set_width(200.);
-                    ui.set_height(60.);
+                    ui.set_height(80.);
                     let response = ui.response();
                     let visuals = ui.style().interact(&response);
 
@@ -287,6 +282,7 @@ impl ValveControlPane {
                         .fill(fill_color)
                         .stroke(Stroke::NONE)
                         .inner_margin(ui.spacing().menu_margin)
+                        .corner_radius(visuals.corner_radius)
                         .show(ui, inside_frame);
 
                     if response.clicked() {
-- 
GitLab


From ea5d75d2335948f78654a10bfba37c87b3ff4093 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Fri, 21 Mar 2025 19:12:50 +0100
Subject: [PATCH 09/24] CHECKPOINT

---
 src/ui/panes/valve_control.rs | 129 ++++++++++++++++++++++++----------
 1 file changed, 91 insertions(+), 38 deletions(-)

diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index e42c201..91cf456 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -5,8 +5,8 @@ mod valves;
 use std::time::{Duration, Instant};
 
 use egui::{
-    Color32, DragValue, Frame, Grid, Label, Layout, Rect, RichText, Sense, Stroke, Ui, UiBuilder,
-    Vec2, Widget, vec2,
+    Color32, DragValue, FontId, Frame, Grid, Label, Layout, Rect, Response, RichText, Sense,
+    Stroke, Ui, UiBuilder, Vec2, Widget, vec2,
 };
 use egui_extras::{Size, StripBuilder};
 use itertools::Itertools;
@@ -145,14 +145,14 @@ impl PaneBehavior for ValveControlPane {
 impl ValveControlPane {
     fn pane_ui(&mut self) -> impl FnOnce(&mut Ui) {
         |ui| {
-            let valve_chunks = Valve::iter().chunks(3);
+            let valve_chunks = Valve::iter().enumerate().chunks(3);
             Grid::new("valves_grid")
                 .num_columns(3)
                 .spacing(Vec2::splat(5.))
                 .show(ui, |ui| {
                     for chunk in &valve_chunks {
-                        for valve in chunk {
-                            ui.scope(self.valve_frame_ui(valve));
+                        for (i, valve) in chunk {
+                            ui.scope(self.valve_frame_ui(valve, i as u8));
                         }
                         ui.end_row();
                     }
@@ -198,7 +198,7 @@ impl ValveControlPane {
         }
     }
 
-    fn valve_frame_ui(&self, valve: Valve) -> impl FnOnce(&mut Ui) {
+    fn valve_frame_ui(&self, valve: Valve, number: u8) -> impl FnOnce(&mut Ui) {
         move |ui| {
             let valve_str = valve.to_string();
             let timing = self.valves_state.get_timing_for(valve);
@@ -222,45 +222,89 @@ impl ValveControlPane {
                     format!("ERROR: {}", err_id)
                 }
             };
+            let text_color = ui.visuals().text_color();
+
+            let valve_title_ui = |ui: &mut Ui| {
+                Label::new(
+                    RichText::new(valve_str.to_ascii_uppercase())
+                        .color(text_color)
+                        .size(16.0),
+                )
+                .selectable(false)
+                .ui(ui);
+            };
 
-            let inside_frame = |ui: &mut Ui| {
-                let text_color = ui.visuals().text_color();
-                let icon_size = Vec2::splat(17.);
+            fn big_number_ui(
+                response: &Response,
+                number: u8,
+                text_color: Color32,
+            ) -> impl Fn(&mut Ui) {
+                move |ui: &mut Ui| {
+                    let visuals = ui.style().interact(response);
+                    let number = RichText::new(number.to_string())
+                        .color(text_color)
+                        .font(FontId::monospace(25.));
+
+                    let fill_color = if response.hovered() {
+                        visuals.bg_fill.gamma_multiply(0.8).to_opaque()
+                    } else {
+                        visuals.bg_fill
+                    };
+
+                    Frame::canvas(ui.style())
+                        .fill(fill_color)
+                        .stroke(Stroke::NONE)
+                        .inner_margin(ui.spacing().menu_margin)
+                        .corner_radius(visuals.corner_radius)
+                        .show(ui, |ui| {
+                            Label::new(number).selectable(false).ui(ui);
+                        });
+                }
+            }
 
-                StripBuilder::new(ui)
-                    .sizes(Size::exact(20.), 2)
-                    .vertical(|mut strip| {
-                        strip.cell(|ui| {
-                            Label::new(
-                                RichText::new(valve_str.to_ascii_uppercase())
-                                    .color(text_color)
-                                    .size(16.0),
-                            )
+            let labels_ui = |ui: &mut Ui| {
+                let icon_size = Vec2::splat(17.);
+                ui.vertical(|ui| {
+                    ui.horizontal(|ui| {
+                        let rect = Rect::from_min_size(ui.cursor().min, icon_size);
+                        Icon::Timing.paint(ui, rect);
+                        ui.allocate_rect(rect, Sense::hover());
+                        Label::new(format!("Timing: {}", timing_str))
                             .selectable(false)
                             .ui(ui);
-                        });
-                        strip.cell(|ui| {
-                            ui.vertical(|ui| {
-                                ui.horizontal(|ui| {
-                                    let rect = Rect::from_min_size(ui.cursor().min, icon_size);
-                                    Icon::Timing.paint(ui, rect);
-                                    ui.allocate_rect(rect, Sense::hover());
-                                    Label::new(format!("Timing: {}", timing_str))
-                                        .selectable(false)
-                                        .ui(ui);
-                                });
+                    });
+                    ui.horizontal(|ui| {
+                        let rect = Rect::from_min_size(ui.cursor().min, icon_size);
+                        Icon::Aperture.paint(ui, rect);
+                        ui.allocate_rect(rect, Sense::hover());
+                        Label::new(format!("Aperture: {}", aperture_str))
+                            .selectable(false)
+                            .ui(ui);
+                    });
+                });
+            };
+
+            fn inside_frame(
+                response: &Response,
+                valve_title_ui: impl Fn(&mut Ui),
+                number: &u8,
+                text_color: &Color32,
+                labels_ui: &impl Fn(&mut Ui),
+            ) -> impl FnOnce(&mut Ui) {
+                |ui: &mut Ui| {
+                    StripBuilder::new(ui)
+                        .sizes(Size::exact(20.), 2)
+                        .vertical(|mut strip| {
+                            strip.cell(valve_title_ui);
+                            strip.cell(|ui| {
                                 ui.horizontal(|ui| {
-                                    let rect = Rect::from_min_size(ui.cursor().min, icon_size);
-                                    Icon::Aperture.paint(ui, rect);
-                                    ui.allocate_rect(rect, Sense::hover());
-                                    Label::new(format!("Aperture: {}", aperture_str))
-                                        .selectable(false)
-                                        .ui(ui);
+                                    big_number_ui(response, *number, *text_color)(ui);
+                                    labels_ui(ui);
                                 });
                             });
                         });
-                    });
-            };
+                }
+            }
 
             ui.scope_builder(
                 UiBuilder::new()
@@ -283,7 +327,16 @@ impl ValveControlPane {
                         .stroke(Stroke::NONE)
                         .inner_margin(ui.spacing().menu_margin)
                         .corner_radius(visuals.corner_radius)
-                        .show(ui, inside_frame);
+                        .show(
+                            ui,
+                            inside_frame(
+                                &response,
+                                &valve_title_ui,
+                                &number,
+                                &text_color,
+                                &labels_ui,
+                            ),
+                        );
 
                     if response.clicked() {
                         info!("Clicked!");
-- 
GitLab


From 5f8ccd8156f7ace2aefb74c0907a431a49215d69 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Fri, 21 Mar 2025 19:49:35 +0100
Subject: [PATCH 10/24] Added minimum size

---
 src/ui/app.rs                 | 4 ++++
 src/ui/panes/valve_control.rs | 6 ++++--
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/src/ui/app.rs b/src/ui/app.rs
index 801b580..a5a1ba1 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -427,6 +427,10 @@ impl Behavior<Pane> for AppBehavior {
     fn tab_title_for_pane(&mut self, _pane: &Pane) -> egui::WidgetText {
         "Tab".into()
     }
+
+    fn min_size(&self) -> f32 {
+        200.0
+    }
 }
 
 #[derive(Clone)]
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 91cf456..87fd3e7 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -145,9 +145,11 @@ impl PaneBehavior for ValveControlPane {
 impl ValveControlPane {
     fn pane_ui(&mut self) -> impl FnOnce(&mut Ui) {
         |ui| {
-            let valve_chunks = Valve::iter().enumerate().chunks(3);
+            ui.set_min_width(200.0);
+            let n = (ui.max_rect().width() / 200.0) as usize;
+            let valve_chunks = Valve::iter().enumerate().chunks(n);
             Grid::new("valves_grid")
-                .num_columns(3)
+                .num_columns(n)
                 .spacing(Vec2::splat(5.))
                 .show(ui, |ui| {
                     for chunk in &valve_chunks {
-- 
GitLab


From e2667484c411e56f0c99a22ffda10eb5449dadb1 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Sat, 22 Mar 2025 14:06:37 +0100
Subject: [PATCH 11/24] Updated refresh settings from frequency to period

---
 src/ui/panes/valve_control.rs | 18 ++++++++++--------
 1 file changed, 10 insertions(+), 8 deletions(-)

diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 87fd3e7..54a5502 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -142,11 +142,12 @@ impl PaneBehavior for ValveControlPane {
 // ┌────────────────────────┐
 // │       UI METHODS       │
 // └────────────────────────┘
+const BTN_WIDTH: f32 = 200.0;
 impl ValveControlPane {
     fn pane_ui(&mut self) -> impl FnOnce(&mut Ui) {
         |ui| {
-            ui.set_min_width(200.0);
-            let n = (ui.max_rect().width() / 200.0) as usize;
+            ui.set_min_width(BTN_WIDTH);
+            let n = (ui.max_rect().width() / BTN_WIDTH) as usize;
             let valve_chunks = Valve::iter().enumerate().chunks(n);
             Grid::new("valves_grid")
                 .num_columns(n)
@@ -184,15 +185,16 @@ impl ValveControlPane {
                 if auto_refresh {
                     let auto_refresh_duration =
                         auto_refresh_setting.get_or_insert(DEFAULT_AUTO_REFRESH_RATE);
-                    let mut auto_refresh_rate = 1. / auto_refresh_duration.as_secs_f32();
-                    DragValue::new(&mut auto_refresh_rate)
-                        .speed(0.1)
-                        .range(0.1..=10.0)
+                    let mut auto_refresh_period = auto_refresh_duration.as_secs_f32();
+                    DragValue::new(&mut auto_refresh_period)
+                        .speed(0.2)
+                        .range(0.5..=5.0)
                         .fixed_decimals(1)
                         .update_while_editing(false)
-                        .suffix(" Hz")
+                        .prefix("Every ")
+                        .suffix(" s")
                         .ui(ui);
-                    *auto_refresh_duration = Duration::from_secs_f32(1. / auto_refresh_rate);
+                    *auto_refresh_duration = Duration::from_secs_f32(auto_refresh_period);
                 } else {
                     *auto_refresh_setting = None;
                 }
-- 
GitLab


From 10e9d768f12ef8c5624f90a7c1731737e1037cf5 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Sat, 22 Mar 2025 17:37:48 +0100
Subject: [PATCH 12/24] CHECKPOINT

UI design of valve buttons
---
 src/ui/app.rs                 |  2 +-
 src/ui/panes/valve_control.rs | 64 +++++++++++++++++++++++------------
 2 files changed, 43 insertions(+), 23 deletions(-)

diff --git a/src/ui/app.rs b/src/ui/app.rs
index a5a1ba1..806b0e4 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -429,7 +429,7 @@ impl Behavior<Pane> for AppBehavior {
     }
 
     fn min_size(&self) -> f32 {
-        200.0
+        185.0
     }
 }
 
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 54a5502..29b6d4f 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -2,11 +2,16 @@ mod commands;
 mod icons;
 mod valves;
 
-use std::time::{Duration, Instant};
+use std::{
+    fmt::format,
+    time::{Duration, Instant},
+};
 
 use egui::{
-    Color32, DragValue, FontId, Frame, Grid, Label, Layout, Rect, Response, RichText, Sense,
-    Stroke, Ui, UiBuilder, Vec2, Widget, vec2,
+    Color32, DragValue, FontId, Frame, Grid, Label, Layout, Margin, Rect, Response, RichText,
+    Sense, Stroke, TextFormat, Ui, UiBuilder, Vec2, Widget, WidgetText,
+    text::{Fonts, LayoutJob},
+    vec2,
 };
 use egui_extras::{Size, StripBuilder};
 use itertools::Itertools;
@@ -142,7 +147,8 @@ impl PaneBehavior for ValveControlPane {
 // ┌────────────────────────┐
 // │       UI METHODS       │
 // └────────────────────────┘
-const BTN_WIDTH: f32 = 200.0;
+const BTN_WIDTH: f32 = 185.0;
+const BTN_HEIGHT: f32 = 70.0;
 impl ValveControlPane {
     fn pane_ui(&mut self) -> impl FnOnce(&mut Ui) {
         |ui| {
@@ -214,16 +220,16 @@ impl ValveControlPane {
                 }
                 valves::ParameterValue::Missing => "N/A".to_owned(),
                 valves::ParameterValue::Invalid(err_id) => {
-                    format!("ERROR: {}", err_id)
+                    format!("ERROR({})", err_id)
                 }
             };
             let aperture_str = match aperture {
                 valves::ParameterValue::Valid(value) => {
-                    format!("{}", value)
+                    format!("{:.2}", value)
                 }
                 valves::ParameterValue::Missing => "N/A".to_owned(),
                 valves::ParameterValue::Invalid(err_id) => {
-                    format!("ERROR: {}", err_id)
+                    format!("ERROR({})", err_id)
                 }
             };
             let text_color = ui.visuals().text_color();
@@ -232,7 +238,7 @@ impl ValveControlPane {
                 Label::new(
                     RichText::new(valve_str.to_ascii_uppercase())
                         .color(text_color)
-                        .size(16.0),
+                        .size(15.0),
                 )
                 .selectable(false)
                 .ui(ui);
@@ -247,7 +253,7 @@ impl ValveControlPane {
                     let visuals = ui.style().interact(response);
                     let number = RichText::new(number.to_string())
                         .color(text_color)
-                        .font(FontId::monospace(25.));
+                        .font(FontId::monospace(20.));
 
                     let fill_color = if response.hovered() {
                         visuals.bg_fill.gamma_multiply(0.8).to_opaque()
@@ -258,7 +264,7 @@ impl ValveControlPane {
                     Frame::canvas(ui.style())
                         .fill(fill_color)
                         .stroke(Stroke::NONE)
-                        .inner_margin(ui.spacing().menu_margin)
+                        .inner_margin(Margin::same(5))
                         .corner_radius(visuals.corner_radius)
                         .show(ui, |ui| {
                             Label::new(number).selectable(false).ui(ui);
@@ -267,23 +273,36 @@ impl ValveControlPane {
             }
 
             let labels_ui = |ui: &mut Ui| {
-                let icon_size = Vec2::splat(17.);
+                let icon_size = Vec2::splat(16.);
+                let text_format = TextFormat {
+                    font_id: FontId::proportional(12.),
+                    extra_letter_spacing: 0.,
+                    line_height: Some(12.),
+                    color: text_color,
+                    ..Default::default()
+                };
                 ui.vertical(|ui| {
-                    ui.horizontal(|ui| {
+                    ui.horizontal_top(|ui| {
                         let rect = Rect::from_min_size(ui.cursor().min, icon_size);
                         Icon::Timing.paint(ui, rect);
                         ui.allocate_rect(rect, Sense::hover());
-                        Label::new(format!("Timing: {}", timing_str))
-                            .selectable(false)
-                            .ui(ui);
+                        let layout_job = LayoutJob::single_section(
+                            format!("Timing: {}", timing_str),
+                            text_format.clone(),
+                        );
+                        let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
+                        Label::new(galley).selectable(false).ui(ui);
                     });
-                    ui.horizontal(|ui| {
+                    ui.horizontal_top(|ui| {
                         let rect = Rect::from_min_size(ui.cursor().min, icon_size);
                         Icon::Aperture.paint(ui, rect);
                         ui.allocate_rect(rect, Sense::hover());
-                        Label::new(format!("Aperture: {}", aperture_str))
-                            .selectable(false)
-                            .ui(ui);
+                        let layout_job = LayoutJob::single_section(
+                            format!("Aperture: {}", aperture_str),
+                            text_format,
+                        );
+                        let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
+                        Label::new(galley).selectable(false).ui(ui);
                     });
                 });
             };
@@ -297,7 +316,8 @@ impl ValveControlPane {
             ) -> impl FnOnce(&mut Ui) {
                 |ui: &mut Ui| {
                     StripBuilder::new(ui)
-                        .sizes(Size::exact(20.), 2)
+                        .size(Size::exact(10.))
+                        .size(Size::exact(15.))
                         .vertical(|mut strip| {
                             strip.cell(valve_title_ui);
                             strip.cell(|ui| {
@@ -315,8 +335,8 @@ impl ValveControlPane {
                     .id_salt("valve_".to_owned() + &valve_str)
                     .sense(Sense::click()),
                 |ui| {
-                    ui.set_width(200.);
-                    ui.set_height(80.);
+                    ui.set_width(BTN_WIDTH);
+                    ui.set_height(BTN_HEIGHT);
                     let response = ui.response();
                     let visuals = ui.style().interact(&response);
 
-- 
GitLab


From fb3886b5d6aad208fcc18cf365974c090fc072e4 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Sun, 23 Mar 2025 10:56:14 +0100
Subject: [PATCH 13/24] CHECKPOINT

---
 src/ui/app.rs                 |  4 --
 src/ui/panes/valve_control.rs | 79 ++++++++++++++++-------------------
 2 files changed, 37 insertions(+), 46 deletions(-)

diff --git a/src/ui/app.rs b/src/ui/app.rs
index 806b0e4..801b580 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -427,10 +427,6 @@ impl Behavior<Pane> for AppBehavior {
     fn tab_title_for_pane(&mut self, _pane: &Pane) -> egui::WidgetText {
         "Tab".into()
     }
-
-    fn min_size(&self) -> f32 {
-        185.0
-    }
 }
 
 #[derive(Clone)]
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 29b6d4f..8488f60 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -147,21 +147,21 @@ impl PaneBehavior for ValveControlPane {
 // ┌────────────────────────┐
 // │       UI METHODS       │
 // └────────────────────────┘
-const BTN_WIDTH: f32 = 185.0;
-const BTN_HEIGHT: f32 = 70.0;
+const BTN_MAX_WIDTH: f32 = 125.;
 impl ValveControlPane {
     fn pane_ui(&mut self) -> impl FnOnce(&mut Ui) {
         |ui| {
-            ui.set_min_width(BTN_WIDTH);
-            let n = (ui.max_rect().width() / BTN_WIDTH) as usize;
-            let valve_chunks = Valve::iter().enumerate().chunks(n);
+            ui.set_min_width(BTN_MAX_WIDTH);
+            let n = ((ui.max_rect().width() / BTN_MAX_WIDTH) as usize).max(1);
+            let symbols: Vec<char> = "123456789-/*".chars().collect();
+            let valve_chunks = Valve::iter().zip(symbols).chunks(n);
             Grid::new("valves_grid")
                 .num_columns(n)
                 .spacing(Vec2::splat(5.))
                 .show(ui, |ui| {
                     for chunk in &valve_chunks {
-                        for (i, valve) in chunk {
-                            ui.scope(self.valve_frame_ui(valve, i as u8));
+                        for (valve, symbol) in chunk {
+                            ui.scope(self.valve_frame_ui(valve, symbol));
                         }
                         ui.end_row();
                     }
@@ -208,7 +208,7 @@ impl ValveControlPane {
         }
     }
 
-    fn valve_frame_ui(&self, valve: Valve, number: u8) -> impl FnOnce(&mut Ui) {
+    fn valve_frame_ui(&self, valve: Valve, symbol: char) -> impl FnOnce(&mut Ui) {
         move |ui| {
             let valve_str = valve.to_string();
             let timing = self.valves_state.get_timing_for(valve);
@@ -225,7 +225,7 @@ impl ValveControlPane {
             };
             let aperture_str = match aperture {
                 valves::ParameterValue::Valid(value) => {
-                    format!("{:.2}", value)
+                    format!("{:.2}%", value * 100.)
                 }
                 valves::ParameterValue::Missing => "N/A".to_owned(),
                 valves::ParameterValue::Invalid(err_id) => {
@@ -235,23 +235,26 @@ impl ValveControlPane {
             let text_color = ui.visuals().text_color();
 
             let valve_title_ui = |ui: &mut Ui| {
+                ui.set_max_width(100.);
                 Label::new(
                     RichText::new(valve_str.to_ascii_uppercase())
                         .color(text_color)
+                        .strong()
                         .size(15.0),
                 )
                 .selectable(false)
+                .wrap()
                 .ui(ui);
             };
 
             fn big_number_ui(
                 response: &Response,
-                number: u8,
+                symbol: char,
                 text_color: Color32,
             ) -> impl Fn(&mut Ui) {
                 move |ui: &mut Ui| {
                     let visuals = ui.style().interact(response);
-                    let number = RichText::new(number.to_string())
+                    let number = RichText::new(symbol.to_string())
                         .color(text_color)
                         .font(FontId::monospace(20.));
 
@@ -273,7 +276,7 @@ impl ValveControlPane {
             }
 
             let labels_ui = |ui: &mut Ui| {
-                let icon_size = Vec2::splat(16.);
+                let icon_size = Vec2::splat(17.);
                 let text_format = TextFormat {
                     font_id: FontId::proportional(12.),
                     extra_letter_spacing: 0.,
@@ -282,25 +285,24 @@ impl ValveControlPane {
                     ..Default::default()
                 };
                 ui.vertical(|ui| {
+                    ui.set_min_width(80.);
                     ui.horizontal_top(|ui| {
                         let rect = Rect::from_min_size(ui.cursor().min, icon_size);
                         Icon::Timing.paint(ui, rect);
                         ui.allocate_rect(rect, Sense::hover());
-                        let layout_job = LayoutJob::single_section(
-                            format!("Timing: {}", timing_str),
-                            text_format.clone(),
-                        );
-                        let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
-                        Label::new(galley).selectable(false).ui(ui);
+                        ui.allocate_ui(vec2(20., 10.), |ui| {
+                            let layout_job =
+                                LayoutJob::single_section(timing_str.clone(), text_format.clone());
+                            let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
+                            Label::new(galley).selectable(false).ui(ui);
+                        });
                     });
                     ui.horizontal_top(|ui| {
                         let rect = Rect::from_min_size(ui.cursor().min, icon_size);
                         Icon::Aperture.paint(ui, rect);
                         ui.allocate_rect(rect, Sense::hover());
-                        let layout_job = LayoutJob::single_section(
-                            format!("Aperture: {}", aperture_str),
-                            text_format,
-                        );
+                        let layout_job =
+                            LayoutJob::single_section(aperture_str.clone(), text_format);
                         let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
                         Label::new(galley).selectable(false).ui(ui);
                     });
@@ -309,24 +311,19 @@ impl ValveControlPane {
 
             fn inside_frame(
                 response: &Response,
-                valve_title_ui: impl Fn(&mut Ui),
-                number: &u8,
-                text_color: &Color32,
-                labels_ui: &impl Fn(&mut Ui),
+                valve_title_ui: impl FnOnce(&mut Ui),
+                symbol: char,
+                text_color: Color32,
+                labels_ui: impl FnOnce(&mut Ui),
             ) -> impl FnOnce(&mut Ui) {
-                |ui: &mut Ui| {
-                    StripBuilder::new(ui)
-                        .size(Size::exact(10.))
-                        .size(Size::exact(15.))
-                        .vertical(|mut strip| {
-                            strip.cell(valve_title_ui);
-                            strip.cell(|ui| {
-                                ui.horizontal(|ui| {
-                                    big_number_ui(response, *number, *text_color)(ui);
-                                    labels_ui(ui);
-                                });
-                            });
+                move |ui: &mut Ui| {
+                    ui.vertical(|ui| {
+                        valve_title_ui(ui);
+                        ui.horizontal(|ui| {
+                            big_number_ui(response, symbol, text_color)(ui);
+                            labels_ui(ui);
                         });
+                    });
                 }
             }
 
@@ -335,8 +332,6 @@ impl ValveControlPane {
                     .id_salt("valve_".to_owned() + &valve_str)
                     .sense(Sense::click()),
                 |ui| {
-                    ui.set_width(BTN_WIDTH);
-                    ui.set_height(BTN_HEIGHT);
                     let response = ui.response();
                     let visuals = ui.style().interact(&response);
 
@@ -356,8 +351,8 @@ impl ValveControlPane {
                             inside_frame(
                                 &response,
                                 &valve_title_ui,
-                                &number,
-                                &text_color,
+                                symbol,
+                                text_color,
                                 &labels_ui,
                             ),
                         );
-- 
GitLab


From ae526f91000865a3b7caf046612f079d4ea42038 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Mon, 24 Mar 2025 14:20:05 +0100
Subject: [PATCH 14/24] CHECKPOINT

added keyboard shortcut manager
---
 src/ui/app.rs                          |  54 ++--
 src/ui/panes.rs                        |   8 +-
 src/ui/panes/default.rs                |   9 +-
 src/ui/panes/messages_viewer.rs        |   4 +-
 src/ui/panes/pid_drawing_tool.rs       |   6 +-
 src/ui/panes/plot.rs                   |   4 +-
 src/ui/panes/valve_control.rs          | 345 +++++++++++++++++++++++--
 src/ui/panes/valve_control/commands.rs |  94 +++++--
 src/ui/panes/valve_control/icons.rs    |  35 ++-
 src/ui/panes/valve_control/valves.rs   |   8 +-
 src/ui/shortcuts.rs                    | 169 +++++++++++-
 src/ui/utils.rs                        |   9 +-
 12 files changed, 640 insertions(+), 105 deletions(-)

diff --git a/src/ui/app.rs b/src/ui/app.rs
index 801b580..b83784b 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -4,7 +4,9 @@ use egui_tiles::{Behavior, Container, Linear, LinearDir, Tile, TileId, Tiles, Tr
 use serde::{Deserialize, Serialize};
 use std::{
     fs,
+    ops::DerefMut,
     path::{Path, PathBuf},
+    sync::{Arc, Mutex},
     time::{Duration, Instant},
 };
 use tracing::{debug, error, trace};
@@ -18,7 +20,7 @@ use crate::{
 use super::{
     panes::{Pane, PaneBehavior, PaneKind},
     persistency::LayoutManager,
-    shortcuts,
+    shortcuts::{ShortcutHandler, ShortcutMode},
     utils::maximized_pane_ui,
     widget_gallery::WidgetGallery,
     widgets::reception_led::ReceptionLed,
@@ -34,6 +36,8 @@ pub struct App {
     // == Message handling ==
     message_broker: MessageBroker,
     message_bundle: MessageBundle,
+    // Shortcut handling
+    shortcut_handler: Arc<Mutex<ShortcutHandler>>,
     // == Windows ==
     widget_gallery: WidgetGallery,
     sources_window: ConnectionsWindow,
@@ -59,17 +63,19 @@ impl eframe::App for App {
         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),
-                ((Modifiers::SHIFT, Key::Escape), PaneAction::Maximize),
-                ((Modifiers::NONE, Key::Escape), PaneAction::Exit),
+                (Modifiers::NONE, Key::V, PaneAction::SplitV),
+                (Modifiers::NONE, Key::H, PaneAction::SplitH),
+                (Modifiers::NONE, Key::C, PaneAction::Close),
+                (Modifiers::NONE, Key::R, PaneAction::ReplaceThroughGallery),
+                (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[..])
-                        .map(|a| (hovered_tile, a)));
+            pane_action = pane_action.or(self
+                .shortcut_handler
+                .lock()
+                .log_unwrap()
+                .consume_if_mode_is(ShortcutMode::composition(), &key_action_pairs[..])
+                .map(|a| (hovered_tile, a)));
         }
 
         // If an action was triggered, we consume it
@@ -220,9 +226,13 @@ impl eframe::App for App {
         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, pane);
+                    maximized_pane_ui(
+                        ui,
+                        pane,
+                        self.shortcut_handler.lock().log_unwrap().deref_mut(),
+                    );
                 } else {
-                    panic!("Maximized pane not found in tree!");
+                    unreachable!("Maximized pane not found in tree!");
                 }
             } else {
                 panes_tree.ui(&mut self.behavior, ui);
@@ -276,14 +286,16 @@ impl App {
                 });
         }
 
+        let shortcut_handler = Arc::new(Mutex::new(ShortcutHandler::new(ctx.egui_ctx.clone())));
         Self {
             state,
             layout_manager,
             message_broker: MessageBroker::new(ctx.egui_ctx.clone()),
             widget_gallery: WidgetGallery::default(),
-            behavior: AppBehavior::default(),
+            behavior: AppBehavior::new(Arc::clone(&shortcut_handler)),
             maximized_pane: None,
             message_bundle: MessageBundle::default(),
+            shortcut_handler,
             sources_window: ConnectionsWindow::default(),
             layout_manager_window: LayoutManagerWindow::default(),
         }
@@ -393,12 +405,22 @@ impl AppState {
 }
 
 /// Behavior for the tree of panes in the app
-#[derive(Default)]
 pub struct AppBehavior {
+    pub shortcut_handler: Arc<Mutex<ShortcutHandler>>,
     pub action: Option<(TileId, PaneAction)>,
     pub tile_id_hovered: Option<TileId>,
 }
 
+impl AppBehavior {
+    fn new(shortcut_handler: Arc<Mutex<ShortcutHandler>>) -> Self {
+        Self {
+            shortcut_handler,
+            action: None,
+            tile_id_hovered: None,
+        }
+    }
+}
+
 impl Behavior<Pane> for AppBehavior {
     fn pane_ui(
         &mut self,
@@ -406,7 +428,7 @@ impl Behavior<Pane> for AppBehavior {
         tile_id: TileId,
         pane: &mut Pane,
     ) -> egui_tiles::UiResponse {
-        let res = ui.scope(|ui| pane.ui(ui));
+        let res = ui.scope(|ui| pane.ui(ui, self.shortcut_handler.lock().log_unwrap().deref_mut()));
         let PaneResponse {
             action_called,
             drag_response,
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index 9e0ea2f..3fa948c 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -11,7 +11,7 @@ use strum_macros::{self, EnumIter, EnumMessage};
 
 use crate::mavlink::{MavMessage, TimedMessage};
 
-use super::app::PaneResponse;
+use super::{app::PaneResponse, shortcuts::ShortcutHandler};
 
 #[derive(Clone, PartialEq, Default, Serialize, Deserialize, Debug)]
 pub struct Pane {
@@ -27,7 +27,7 @@ impl Pane {
 #[enum_dispatch(PaneKind)]
 pub trait PaneBehavior {
     /// Renders the UI of the pane.
-    fn ui(&mut self, ui: &mut Ui) -> PaneResponse;
+    fn ui(&mut self, ui: &mut Ui, shortcut_handler: &mut ShortcutHandler) -> PaneResponse;
 
     /// Updates the pane state. This method is called before `ui` to allow the
     /// pane to update its state based on the messages received.
@@ -50,8 +50,8 @@ pub trait PaneBehavior {
 }
 
 impl PaneBehavior for Pane {
-    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
-        self.pane.ui(ui)
+    fn ui(&mut self, ui: &mut Ui, shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
+        self.pane.ui(ui, shortcut_handler)
     }
 
     fn update(&mut self, messages: &[&TimedMessage]) {
diff --git a/src/ui/panes/default.rs b/src/ui/panes/default.rs
index e0419ca..dd2406f 100644
--- a/src/ui/panes/default.rs
+++ b/src/ui/panes/default.rs
@@ -7,6 +7,7 @@ use crate::{
     mavlink::TimedMessage,
     ui::{
         app::{PaneAction, PaneResponse},
+        shortcuts::ShortcutHandler,
         utils::{SizingMemo, vertically_centered},
     },
 };
@@ -27,7 +28,7 @@ impl PartialEq for DefaultPane {
 
 impl PaneBehavior for DefaultPane {
     #[profiling::function]
-    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui, _shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
         let mut response = PaneResponse::default();
 
         let parent = vertically_centered(ui, &mut self.centering_memo, |ui| {
@@ -49,11 +50,7 @@ impl PaneBehavior for DefaultPane {
 
         self.contains_pointer = parent.contains_pointer();
 
-        if parent
-            .interact(egui::Sense::click_and_drag())
-            .on_hover_cursor(egui::CursorIcon::Grab)
-            .dragged()
-        {
+        if parent.interact(egui::Sense::click_and_drag()).dragged() {
             response.set_drag_started();
         };
 
diff --git a/src/ui/panes/messages_viewer.rs b/src/ui/panes/messages_viewer.rs
index cfee6c6..eb96466 100644
--- a/src/ui/panes/messages_viewer.rs
+++ b/src/ui/panes/messages_viewer.rs
@@ -1,7 +1,7 @@
 use egui::{Label, Ui};
 use serde::{Deserialize, Serialize};
 
-use crate::ui::app::PaneResponse;
+use crate::ui::{app::PaneResponse, shortcuts::ShortcutHandler};
 
 use super::PaneBehavior;
 
@@ -10,7 +10,7 @@ pub struct MessagesViewerPane;
 
 impl PaneBehavior for MessagesViewerPane {
     #[profiling::function]
-    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui, _shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
         let mut response = PaneResponse::default();
         let label = ui.add_sized(ui.available_size(), Label::new("This is a label"));
         if label.drag_started() {
diff --git a/src/ui/panes/pid_drawing_tool.rs b/src/ui/panes/pid_drawing_tool.rs
index 05160cc..9257cf4 100644
--- a/src/ui/panes/pid_drawing_tool.rs
+++ b/src/ui/panes/pid_drawing_tool.rs
@@ -19,7 +19,9 @@ use crate::{
     MAVLINK_PROFILE,
     error::ErrInstrument,
     mavlink::{GSE_TM_DATA, MessageData, TimedMessage, reflection::MessageLike},
-    ui::{app::PaneResponse, cache::ChangeTracker, utils::egui_to_glam},
+    ui::{
+        app::PaneResponse, cache::ChangeTracker, shortcuts::ShortcutHandler, utils::egui_to_glam,
+    },
 };
 
 use super::PaneBehavior;
@@ -79,7 +81,7 @@ impl PartialEq for PidPane {
 }
 
 impl PaneBehavior for PidPane {
-    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui, _shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
         let mut pane_response = PaneResponse::default();
 
         let theme = PidPane::find_theme(ui.ctx());
diff --git a/src/ui/panes/plot.rs b/src/ui/panes/plot.rs
index af83540..e4616d1 100644
--- a/src/ui/panes/plot.rs
+++ b/src/ui/panes/plot.rs
@@ -8,7 +8,7 @@ use crate::{
         MessageData, ROCKET_FLIGHT_TM_DATA, TimedMessage,
         reflection::{FieldLike, IndexedField},
     },
-    ui::app::PaneResponse,
+    ui::{app::PaneResponse, shortcuts::ShortcutHandler},
     utils::units::UnitOfMeasure,
 };
 use egui::{Color32, Ui, Vec2, Vec2b};
@@ -41,7 +41,7 @@ impl PartialEq for Plot2DPane {
 
 impl PaneBehavior for Plot2DPane {
     #[profiling::function]
-    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui, _shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
         let mut response = PaneResponse::default();
         let data_settings_digest = self.settings.data_digest();
 
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 8488f60..6564ae3 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -3,17 +3,15 @@ mod icons;
 mod valves;
 
 use std::{
-    fmt::format,
+    collections::HashMap,
     time::{Duration, Instant},
 };
 
 use egui::{
-    Color32, DragValue, FontId, Frame, Grid, Label, Layout, Margin, Rect, Response, RichText,
-    Sense, Stroke, TextFormat, Ui, UiBuilder, Vec2, Widget, WidgetText,
-    text::{Fonts, LayoutJob},
+    Color32, DragValue, FontId, Frame, Grid, Key, Label, Margin, Modal, Modifiers, Response,
+    RichText, Sense, Stroke, TextFormat, Ui, UiBuilder, Vec2, Widget, Window, text::LayoutJob,
     vec2,
 };
-use egui_extras::{Size, StripBuilder};
 use itertools::Itertools;
 use serde::{Deserialize, Serialize};
 use skyward_mavlink::{
@@ -21,22 +19,26 @@ use skyward_mavlink::{
     orion::{ACK_TM_DATA, NACK_TM_DATA, WACK_TM_DATA},
 };
 use strum::IntoEnumIterator;
-use tracing::{info, warn};
+use tracing::{error, info, warn};
 
 use crate::{
     mavlink::{MavMessage, TimedMessage},
-    ui::app::PaneResponse,
+    ui::{
+        app::PaneResponse,
+        shortcuts::{ShortcutHandler, ShortcutMode},
+    },
 };
 
 use super::PaneBehavior;
 
-use commands::CommandSM;
+use commands::{Command, CommandSM};
 use icons::Icon;
 use valves::{Valve, ValveStateManager};
 
 const DEFAULT_AUTO_REFRESH_RATE: Duration = Duration::from_secs(1);
+const SYMBOL_LIST: &str = "123456789-/.";
 
-#[derive(Clone, PartialEq, Default, Serialize, Deserialize, Debug)]
+#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
 pub struct ValveControlPane {
     // INTERNAL
     #[serde(skip)]
@@ -58,12 +60,39 @@ pub struct ValveControlPane {
     // UI SETTINGS
     #[serde(skip)]
     is_settings_window_open: bool,
+    #[serde(skip)]
+    valve_symbol_map: HashMap<char, Valve>,
+    #[serde(skip)]
+    valve_window_states: HashMap<Valve, ValveWindowState>,
+}
+
+impl Default for ValveControlPane {
+    fn default() -> Self {
+        let symbols: Vec<char> = SYMBOL_LIST.chars().collect();
+        let valve_symbol = symbols.into_iter().zip(Valve::iter()).collect();
+        let valve_window_states = Valve::iter()
+            .map(|v| (v, ValveWindowState::Closed))
+            .collect();
+        Self {
+            valves_state: ValveStateManager::default(),
+            commands: vec![],
+            auto_refresh: None,
+            manual_refresh: false,
+            last_refresh: None,
+            is_settings_window_open: false,
+            valve_symbol_map: valve_symbol,
+            valve_window_states,
+        }
+    }
 }
 
 impl PaneBehavior for ValveControlPane {
-    fn ui(&mut self, ui: &mut Ui) -> PaneResponse {
+    fn ui(&mut self, ui: &mut Ui, shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
         let mut pane_response = PaneResponse::default();
 
+        // Set this to at least double the maximum icon size used
+        Icon::init_cache(ui.ctx(), (100, 100));
+
         let res = ui
             .scope_builder(UiBuilder::new().sense(Sense::click_and_drag()), |ui| {
                 self.pane_ui()(ui);
@@ -79,13 +108,46 @@ impl PaneBehavior for ValveControlPane {
             pane_response.set_drag_started();
         }
 
-        egui::Window::new("Settings")
+        // capture actions from keyboard shortcuts
+        let action_to_pass = self.keyboard_actions(shortcut_handler);
+
+        match action_to_pass {
+            // Open the valve control window if the action is to open it
+            Some(PaneAction::OpenValveControl(valve)) => {
+                self.valve_window_states
+                    .insert(valve, ValveWindowState::Open);
+            }
+            // Close if the user requests so
+            Some(PaneAction::CloseValveControls) => {
+                warn!("closing all");
+                for valve in Valve::iter() {
+                    self.valve_window_states
+                        .insert(valve, ValveWindowState::Closed);
+                }
+            }
+            // Ignore otherwise
+            _ => {}
+        }
+
+        Window::new("Settings")
             .id(ui.auto_id_with("settings"))
             .auto_sized()
             .collapsible(true)
             .movable(true)
             .open(&mut self.is_settings_window_open)
-            .show(ui.ctx(), Self::window_ui(&mut self.auto_refresh));
+            .show(ui.ctx(), Self::settings_window_ui(&mut self.auto_refresh));
+
+        if let Some(valve_window_open) = self
+            .valve_window_states
+            .iter()
+            .find(|&(_, state)| !state.is_closed())
+            .map(|(&v, _)| v)
+        {
+            Modal::new(ui.auto_id_with(format!("valve_control {}", valve_window_open))).show(
+                ui.ctx(),
+                self.valve_control_window_ui(valve_window_open, action_to_pass),
+            );
+        }
 
         pane_response
     }
@@ -118,7 +180,7 @@ impl PaneBehavior for ValveControlPane {
                 // intercept all ACK/NACK/WACK messages
                 cmd.capture_response(&message.message);
                 // If a response was captured, consume the command and update the valve state
-                if let Some((valve, parameter)) = cmd.consume_response() {
+                if let Some((valve, Some(parameter))) = cmd.consume_response() {
                     self.valves_state.set_parameter_of(valve, parameter);
                 }
             }
@@ -152,15 +214,14 @@ impl ValveControlPane {
     fn pane_ui(&mut self) -> impl FnOnce(&mut Ui) {
         |ui| {
             ui.set_min_width(BTN_MAX_WIDTH);
-            let n = ((ui.max_rect().width() / BTN_MAX_WIDTH) as usize).max(1);
-            let symbols: Vec<char> = "123456789-/*".chars().collect();
-            let valve_chunks = Valve::iter().zip(symbols).chunks(n);
+            let n = (ui.max_rect().width() / BTN_MAX_WIDTH) as usize;
+            let valve_chunks = SYMBOL_LIST.chars().zip(Valve::iter()).chunks(n.max(1));
             Grid::new("valves_grid")
                 .num_columns(n)
                 .spacing(Vec2::splat(5.))
                 .show(ui, |ui| {
                     for chunk in &valve_chunks {
-                        for (valve, symbol) in chunk {
+                        for (symbol, valve) in chunk {
                             ui.scope(self.valve_frame_ui(valve, symbol));
                         }
                         ui.end_row();
@@ -182,7 +243,7 @@ impl ValveControlPane {
         }
     }
 
-    fn window_ui(auto_refresh_setting: &mut Option<Duration>) -> impl FnOnce(&mut Ui) {
+    fn settings_window_ui(auto_refresh_setting: &mut Option<Duration>) -> impl FnOnce(&mut Ui) {
         |ui| {
             // Display auto refresh setting
             let mut auto_refresh = auto_refresh_setting.is_some();
@@ -287,9 +348,12 @@ impl ValveControlPane {
                 ui.vertical(|ui| {
                     ui.set_min_width(80.);
                     ui.horizontal_top(|ui| {
-                        let rect = Rect::from_min_size(ui.cursor().min, icon_size);
-                        Icon::Timing.paint(ui, rect);
-                        ui.allocate_rect(rect, Sense::hover());
+                        ui.add(
+                            Icon::Timing
+                                .as_image(ui.ctx().theme())
+                                .fit_to_exact_size(icon_size)
+                                .sense(Sense::hover()),
+                        );
                         ui.allocate_ui(vec2(20., 10.), |ui| {
                             let layout_job =
                                 LayoutJob::single_section(timing_str.clone(), text_format.clone());
@@ -298,9 +362,12 @@ impl ValveControlPane {
                         });
                     });
                     ui.horizontal_top(|ui| {
-                        let rect = Rect::from_min_size(ui.cursor().min, icon_size);
-                        Icon::Aperture.paint(ui, rect);
-                        ui.allocate_rect(rect, Sense::hover());
+                        ui.add(
+                            Icon::Aperture
+                                .as_image(ui.ctx().theme())
+                                .fit_to_exact_size(icon_size)
+                                .sense(Sense::hover()),
+                        );
                         let layout_job =
                             LayoutJob::single_section(aperture_str.clone(), text_format);
                         let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
@@ -364,6 +431,200 @@ impl ValveControlPane {
             );
         }
     }
+
+    const WIGGLE_KEY: Key = Key::Minus;
+    const TIMING_KEY: Key = Key::Slash;
+    const APERTURE_KEY: Key = Key::Period;
+
+    fn valve_control_window_ui(
+        &mut self,
+        valve: Valve,
+        action: Option<PaneAction>,
+    ) -> impl FnOnce(&mut Ui) {
+        move |ui| {
+            let icon_size = Vec2::splat(25.);
+            let text_size = 16.;
+
+            fn btn_ui<R>(
+                valve: Valve,
+                add_contents: impl FnOnce(&mut Ui) -> R,
+            ) -> impl FnOnce(&mut Ui) -> Response {
+                move |ui| {
+                    let mut wiggle_btn = Frame::canvas(ui.style())
+                        .inner_margin(ui.spacing().menu_margin)
+                        .corner_radius(ui.visuals().noninteractive().corner_radius);
+
+                    wiggle_btn = ui.ctx().input(|input| {
+                        if input.key_down(ValveControlPane::WIGGLE_KEY) {
+                            wiggle_btn
+                                .fill(ui.visuals().widgets.active.bg_fill)
+                                .stroke(ui.visuals().widgets.active.fg_stroke)
+                        } else {
+                            wiggle_btn
+                                .fill(ui.visuals().widgets.inactive.bg_fill.gamma_multiply(0.3))
+                                .stroke(Stroke::new(2.0, Color32::TRANSPARENT))
+                        }
+                    });
+
+                    ui.scope_builder(
+                        UiBuilder::new()
+                            .id_salt(format!("valve_control_window_{}_wiggle", valve))
+                            .sense(Sense::click()),
+                        |ui| {
+                            wiggle_btn.show(ui, |ui| ui.horizontal(|ui| add_contents(ui)));
+                        },
+                    )
+                    .response
+                }
+            }
+
+            let wiggle_btn_response = btn_ui(valve, |ui| {
+                ui.add(
+                    Icon::Aperture
+                        .as_image(ui.ctx().theme())
+                        .fit_to_exact_size(icon_size),
+                );
+                ui.add(Label::new(RichText::new("Wiggle").size(text_size)).selectable(false));
+            })(ui);
+
+            let mut aperture = 0_u32;
+            let aperture_btn_response = btn_ui(valve, |ui| {
+                ui.add(
+                    Icon::Aperture
+                        .as_image(ui.ctx().theme())
+                        .fit_to_exact_size(icon_size),
+                );
+                ui.add(Label::new(RichText::new("Aperture: ").size(text_size)).selectable(false));
+                ui.add(
+                    DragValue::new(&mut aperture)
+                        .speed(0.5)
+                        .range(0.0..=100.0)
+                        .fixed_decimals(1)
+                        .update_while_editing(false)
+                        .suffix("%"),
+                );
+            })(ui);
+
+            let mut timing_ms = 0_u32;
+            let timing_btn_response = btn_ui(valve, |ui| {
+                ui.add(
+                    Icon::Timing
+                        .as_image(ui.ctx().theme())
+                        .fit_to_exact_size(icon_size),
+                );
+                ui.add(Label::new(RichText::new("Timing: ").size(text_size)).selectable(false));
+                ui.add(
+                    DragValue::new(&mut timing_ms)
+                        .speed(1)
+                        .range(1..=10000)
+                        .fixed_decimals(0)
+                        .update_while_editing(false)
+                        .suffix(" [ms]"),
+                );
+            })(ui);
+
+            if wiggle_btn_response.clicked() || matches!(action, Some(PaneAction::Wiggle)) {
+                info!("Wiggle valve: {:?}", valve);
+                self.commands.push(Command::wiggle(valve).into());
+            }
+            // self.valve_window_states
+            //     .insert(valve, ValveWindowState::Closed);
+        }
+    }
+
+    fn keyboard_actions(&self, shortcut_handler: &mut ShortcutHandler) -> Option<PaneAction> {
+        let mut key_action_pairs = Vec::new();
+        match self
+            .valve_window_states
+            .iter()
+            .find(|&(_, open)| !open.is_closed())
+        {
+            Some((&valve, state)) => {
+                shortcut_handler.activate_mode(ShortcutMode::valve_control());
+                match state {
+                    ValveWindowState::Open => {
+                        // A window is open, so we can map the keys to control the valve
+                        key_action_pairs.push((
+                            Modifiers::NONE,
+                            Self::WIGGLE_KEY,
+                            PaneAction::Wiggle,
+                        ));
+                        key_action_pairs.push((
+                            Modifiers::NONE,
+                            Self::TIMING_KEY,
+                            PaneAction::FocusOnTiming,
+                        ));
+                        key_action_pairs.push((
+                            Modifiers::NONE,
+                            Self::APERTURE_KEY,
+                            PaneAction::FocusOnAperture,
+                        ));
+                        key_action_pairs.push((
+                            Modifiers::NONE,
+                            Key::Escape,
+                            PaneAction::CloseValveControls,
+                        ));
+                    }
+                    ValveWindowState::TimingFocused => {
+                        // The timing field is focused, so we can map the keys to control the timing
+                        key_action_pairs.push((Modifiers::NONE, Key::Enter, PaneAction::SetTiming));
+                        key_action_pairs.push((
+                            Modifiers::NONE,
+                            Key::Escape,
+                            PaneAction::OpenValveControl(valve),
+                        ));
+                    }
+                    ValveWindowState::ApertureFocused => {
+                        // The aperture field is focused, so we can map the keys to control the aperture
+                        key_action_pairs.push((
+                            Modifiers::NONE,
+                            Key::Enter,
+                            PaneAction::SetAperture,
+                        ));
+                        key_action_pairs.push((
+                            Modifiers::NONE,
+                            Key::Escape,
+                            PaneAction::OpenValveControl(valve),
+                        ));
+                    }
+                    ValveWindowState::Closed => unreachable!(),
+                }
+                shortcut_handler
+                    .consume_if_mode_is(ShortcutMode::valve_control(), &key_action_pairs[..])
+            }
+            None => {
+                shortcut_handler.deactivate_mode(ShortcutMode::valve_control());
+                // No window is open, so we can map the keys to open the valve control windows
+                for &symbol in self.valve_symbol_map.keys() {
+                    let key = match symbol {
+                        '1' => Key::Num1,
+                        '2' => Key::Num2,
+                        '3' => Key::Num3,
+                        '4' => Key::Num4,
+                        '5' => Key::Num5,
+                        '6' => Key::Num6,
+                        '7' => Key::Num7,
+                        '8' => Key::Num8,
+                        '9' => Key::Num9,
+                        '-' => Key::Minus,
+                        '/' => Key::Slash,
+                        '.' => Key::Period,
+                        _ => {
+                            error!("Invalid symbol: {}", symbol);
+                            panic!("Invalid symbol: {}", symbol);
+                        }
+                    };
+                    key_action_pairs.push((
+                        Modifiers::NONE,
+                        key,
+                        PaneAction::OpenValveControl(self.valve_symbol_map[&symbol]),
+                    ));
+                }
+                shortcut_handler
+                    .consume_if_mode_is(ShortcutMode::composition(), &key_action_pairs[..])
+            }
+        }
+    }
 }
 
 // ┌───────────────────────────┐
@@ -389,3 +650,39 @@ impl ValveControlPane {
         self.manual_refresh = false;
     }
 }
+
+#[derive(Debug, Clone, Copy)]
+enum PaneAction {
+    OpenValveControl(Valve),
+    CloseValveControls,
+    Wiggle,
+    SetTiming,
+    SetAperture,
+    FocusOnTiming,
+    FocusOnAperture,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ValveWindowState {
+    Closed,
+    Open,
+    TimingFocused,
+    ApertureFocused,
+}
+
+impl ValveWindowState {
+    #[inline]
+    fn is_open(&self) -> bool {
+        matches!(self, Self::Open)
+    }
+
+    #[inline]
+    fn is_closed(&self) -> bool {
+        matches!(self, Self::Closed)
+    }
+
+    #[inline]
+    fn is_focused(&self) -> bool {
+        matches!(self, Self::TimingFocused | Self::ApertureFocused)
+    }
+}
diff --git a/src/ui/panes/valve_control/commands.rs b/src/ui/panes/valve_control/commands.rs
index 8593fd9..ed4d282 100644
--- a/src/ui/panes/valve_control/commands.rs
+++ b/src/ui/panes/valve_control/commands.rs
@@ -1,3 +1,7 @@
+use std::time::{Duration, Instant};
+
+use skyward_mavlink::orion::WIGGLE_SERVO_TC_DATA;
+
 use crate::mavlink::{
     ACK_TM_DATA, MavMessage, MessageData, NACK_TM_DATA, SET_ATOMIC_VALVE_TIMING_TC_DATA,
     SET_VALVE_MAXIMUM_APERTURE_TC_DATA, WACK_TM_DATA,
@@ -8,25 +12,35 @@ use super::valves::{ParameterValue, Valve, ValveParameter};
 #[derive(Debug, Clone, PartialEq)]
 pub enum CommandSM {
     Request(Command),
-    WaitingForResponse(Command),
-    Response((Valve, ValveParameter)),
+    WaitingForResponse((Instant, Command)),
+    Response((Valve, Option<ValveParameter>)),
     Consumed,
 }
 
 impl CommandSM {
     pub fn pack_and_wait(&mut self) -> Option<MavMessage> {
         match self {
-            CommandSM::Request(command) => {
+            Self::Request(command) => {
                 let message = MavMessage::from(command.clone());
-                *self = CommandSM::WaitingForResponse(command.clone());
+                *self = CommandSM::WaitingForResponse((Instant::now(), command.clone()));
                 Some(message)
             }
             _ => None,
         }
     }
 
+    pub fn cancel_expired(&mut self, timeout: Duration) {
+        if let Self::WaitingForResponse((instant, cmd)) = self {
+            if instant.elapsed() > timeout {
+                let Command { kind, valve } = cmd;
+                // *self = Self::Response(valve, kind.to_invalid_parameter(error));
+                todo!() // TODO
+            }
+        }
+    }
+
     pub fn capture_response(&mut self, message: &MavMessage) {
-        if let CommandSM::WaitingForResponse(Command { kind, valve }) = self {
+        if let Self::WaitingForResponse((_, Command { kind, valve })) = self {
             let id = kind.message_id() as u8;
             match message {
                 MavMessage::ACK_TM(ACK_TM_DATA { recv_msgid, .. }) if *recv_msgid == id => {
@@ -47,9 +61,9 @@ impl CommandSM {
         }
     }
 
-    pub fn consume_response(&mut self) -> Option<(Valve, ValveParameter)> {
+    pub fn consume_response(&mut self) -> Option<(Valve, Option<ValveParameter>)> {
         match self {
-            CommandSM::Response((valve, parameter)) => {
+            Self::Response((valve, parameter)) => {
                 let res = Some((*valve, parameter.clone()));
                 *self = CommandSM::Consumed;
                 res
@@ -59,17 +73,17 @@ impl CommandSM {
     }
 
     pub fn is_waiting_for_response(&self) -> bool {
-        matches!(self, CommandSM::WaitingForResponse(_))
+        matches!(self, Self::WaitingForResponse(_))
     }
 
     pub fn is_consumed(&self) -> bool {
-        matches!(self, CommandSM::Consumed)
+        matches!(self, Self::Consumed)
     }
 }
 
 impl From<Command> for CommandSM {
     fn from(value: Command) -> Self {
-        CommandSM::Request(value)
+        Self::Request(value)
     }
 }
 
@@ -100,17 +114,37 @@ pub struct Command {
     valve: Valve,
 }
 
+impl Command {
+    pub fn wiggle(valve: Valve) -> Self {
+        Self {
+            kind: CommandKind::Wiggle,
+            valve,
+        }
+    }
+
+    pub fn set_atomic_valve_timing(valve: Valve, timing: u32) -> Self {
+        valve.set_atomic_valve_timing(timing)
+    }
+
+    pub fn set_valve_maximum_aperture(valve: Valve, aperture: f32) -> Self {
+        valve.set_valve_maximum_aperture(aperture)
+    }
+}
+
 impl From<Command> for MavMessage {
     fn from(value: Command) -> Self {
         match value.kind {
+            CommandKind::Wiggle => Self::WIGGLE_SERVO_TC(WIGGLE_SERVO_TC_DATA {
+                servo_id: value.valve.into(),
+            }),
             CommandKind::SetAtomicValveTiming(timing) => {
-                MavMessage::SET_ATOMIC_VALVE_TIMING_TC(SET_ATOMIC_VALVE_TIMING_TC_DATA {
+                Self::SET_ATOMIC_VALVE_TIMING_TC(SET_ATOMIC_VALVE_TIMING_TC_DATA {
                     servo_id: value.valve.into(),
                     maximum_timing: timing,
                 })
             }
             CommandKind::SetValveMaximumAperture(aperture) => {
-                MavMessage::SET_VALVE_MAXIMUM_APERTURE_TC(SET_VALVE_MAXIMUM_APERTURE_TC_DATA {
+                Self::SET_VALVE_MAXIMUM_APERTURE_TC(SET_VALVE_MAXIMUM_APERTURE_TC_DATA {
                     servo_id: value.valve.into(),
                     maximum_aperture: aperture,
                 })
@@ -121,6 +155,7 @@ impl From<Command> for MavMessage {
 
 #[derive(Debug, Clone, Copy, PartialEq)]
 enum CommandKind {
+    Wiggle,
     SetAtomicValveTiming(u32),
     SetValveMaximumAperture(f32),
 }
@@ -128,35 +163,40 @@ enum CommandKind {
 impl CommandKind {
     fn message_id(&self) -> u32 {
         match self {
-            CommandKind::SetAtomicValveTiming(_) => SET_ATOMIC_VALVE_TIMING_TC_DATA::ID,
-            CommandKind::SetValveMaximumAperture(_) => SET_VALVE_MAXIMUM_APERTURE_TC_DATA::ID,
+            Self::Wiggle => WIGGLE_SERVO_TC_DATA::ID,
+            Self::SetAtomicValveTiming(_) => SET_ATOMIC_VALVE_TIMING_TC_DATA::ID,
+            Self::SetValveMaximumAperture(_) => SET_VALVE_MAXIMUM_APERTURE_TC_DATA::ID,
         }
     }
 
-    fn to_valid_parameter(&self) -> ValveParameter {
-        (*self).into()
+    fn to_valid_parameter(&self) -> Option<ValveParameter> {
+        (*self).try_into().ok()
     }
 
-    fn to_invalid_parameter(&self, error: u16) -> ValveParameter {
+    fn to_invalid_parameter(&self, error: u16) -> Option<ValveParameter> {
         match self {
-            CommandKind::SetAtomicValveTiming(_) => {
-                ValveParameter::AtomicValveTiming(ParameterValue::Invalid(error))
-            }
-            CommandKind::SetValveMaximumAperture(_) => {
-                ValveParameter::ValveMaximumAperture(ParameterValue::Invalid(error))
-            }
+            Self::Wiggle => None,
+            Self::SetAtomicValveTiming(_) => Some(ValveParameter::AtomicValveTiming(
+                ParameterValue::Invalid(error),
+            )),
+            Self::SetValveMaximumAperture(_) => Some(ValveParameter::ValveMaximumAperture(
+                ParameterValue::Invalid(error),
+            )),
         }
     }
 }
 
-impl From<CommandKind> for ValveParameter {
-    fn from(value: CommandKind) -> Self {
+impl TryFrom<CommandKind> for ValveParameter {
+    type Error = ();
+
+    fn try_from(value: CommandKind) -> Result<Self, Self::Error> {
         match value {
+            CommandKind::Wiggle => Err(()),
             CommandKind::SetAtomicValveTiming(timing) => {
-                ValveParameter::AtomicValveTiming(ParameterValue::Valid(timing))
+                Ok(Self::AtomicValveTiming(ParameterValue::Valid(timing)))
             }
             CommandKind::SetValveMaximumAperture(aperture) => {
-                ValveParameter::ValveMaximumAperture(ParameterValue::Valid(aperture))
+                Ok(Self::ValveMaximumAperture(ParameterValue::Valid(aperture)))
             }
         }
     }
diff --git a/src/ui/panes/valve_control/icons.rs b/src/ui/panes/valve_control/icons.rs
index 064fee5..f8d70a9 100644
--- a/src/ui/panes/valve_control/icons.rs
+++ b/src/ui/panes/valve_control/icons.rs
@@ -1,13 +1,18 @@
-use egui::{ImageSource, Rect, Theme, Ui};
+use egui::{Context, Image, ImageSource, SizeHint, TextureOptions, Theme, Ui};
+use strum::IntoEnumIterator;
+use strum_macros::EnumIter;
+use tracing::error;
 
-#[derive(Debug, Clone, Copy)]
+use crate::error::ErrInstrument;
+
+#[derive(Debug, Clone, Copy, EnumIter)]
 pub enum Icon {
     Aperture,
     Timing,
 }
 
 impl Icon {
-    fn get_image(&self, theme: Theme) -> ImageSource {
+    fn as_image_source(&self, theme: Theme) -> ImageSource {
         match (&self, theme) {
             (Icon::Aperture, Theme::Light) => {
                 egui::include_image!(concat!(
@@ -35,11 +40,25 @@ impl Icon {
             }
         }
     }
-}
 
-impl Icon {
-    pub fn paint(&mut self, ui: &mut Ui, image_rect: Rect) {
-        let theme = ui.ctx().theme();
-        egui::Image::new(self.get_image(theme)).paint_at(ui, image_rect);
+    pub fn init_cache(ctx: &Context, size_hint: (u32, u32)) {
+        let size_hint = SizeHint::Size(size_hint.0, size_hint.1);
+        for icon in Self::iter() {
+            if let Err(e) =
+                icon.as_image_source(ctx.theme())
+                    .load(ctx, TextureOptions::LINEAR, size_hint)
+            {
+                error!("Error loading icons: {}", e);
+            }
+        }
+    }
+
+    pub fn as_image(&self, theme: Theme) -> Image {
+        Image::new(self.as_image_source(theme))
+    }
+
+    pub fn reset_cache(&self, ui: &mut Ui) {
+        let img: Image = self.as_image(ui.ctx().theme());
+        ui.ctx().forget_image(img.uri().log_unwrap());
     }
 }
diff --git a/src/ui/panes/valve_control/valves.rs b/src/ui/panes/valve_control/valves.rs
index 9580dc1..cf13fcb 100644
--- a/src/ui/panes/valve_control/valves.rs
+++ b/src/ui/panes/valve_control/valves.rs
@@ -63,7 +63,7 @@ impl ValveStateManager {
 }
 
 #[allow(non_camel_case_types)]
-#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter, Hash)]
 pub enum Valve {
     OxFilling,
     OxRelease,
@@ -131,9 +131,9 @@ pub enum ParameterValue<T, E> {
 impl<T: Display, E: Display> Display for ParameterValue<T, E> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
-            ParameterValue::Valid(value) => write!(f, "{}", value),
-            ParameterValue::Missing => write!(f, "MISSING"),
-            ParameterValue::Invalid(error) => write!(f, "INVALID: {}", error),
+            Self::Valid(value) => write!(f, "{}", value),
+            Self::Missing => write!(f, "MISSING"),
+            Self::Invalid(error) => write!(f, "INVALID: {}", error),
         }
     }
 }
diff --git a/src/ui/shortcuts.rs b/src/ui/shortcuts.rs
index 7efa69f..d95bcaf 100644
--- a/src/ui/shortcuts.rs
+++ b/src/ui/shortcuts.rs
@@ -1,10 +1,165 @@
+use std::collections::HashSet;
+
 use egui::{Context, Key, KeyboardShortcut, Modifiers};
 
-pub fn map_to_action<A: Clone>(ctx: &Context, pairs: &[((Modifiers, Key), A)]) -> Option<A> {
-    ctx.input_mut(|i| {
-        pairs.iter().find_map(|((modifier, key), action)| {
-            i.consume_shortcut(&KeyboardShortcut::new(*modifier, *key))
-                .then_some(action.to_owned())
-        })
-    })
+/// Contains all keyboard shortcuts added by the UI.
+///
+/// [`ShortcutHandler`] is used to register shortcuts and consume them, while
+/// keeping tracks of all enabled shortcuts and filter active shortcut based on
+/// UI views and modes (see [`ShortcutModeStack`]).
+#[derive(Debug, Clone)]
+pub struct ShortcutHandler {
+    /// The egui context. Needed to consume shortcuts.
+    ctx: Context,
+
+    /// Set of all enabled shortcuts.
+    enabled_shortcuts: HashSet<KeyboardShortcut>,
+
+    /// Stack layers of keyboard shortcuts. Controls which shortcuts are active at any given time.
+    mode_stack: ShortcutModeStack,
+}
+
+impl ShortcutHandler {
+    pub fn new(ctx: Context) -> Self {
+        Self {
+            ctx,
+            enabled_shortcuts: Default::default(),
+            mode_stack: Default::default(),
+        }
+    }
+
+    fn add_shortcut_action_pair<A>(
+        &mut self,
+        modifier: Modifiers,
+        key: Key,
+        action: A,
+        mode: ShortcutMode,
+    ) -> Option<A> {
+        let shortcut = KeyboardShortcut::new(modifier, key);
+        if self.mode_stack.is_active(mode) {
+            let action = self
+                .ctx
+                .input_mut(|i| i.consume_shortcut(&shortcut).then_some(action));
+            self.enabled_shortcuts.insert(shortcut);
+            action
+        } else {
+            None
+        }
+    }
+
+    /// Consume the keyboard shortcut provided and return the action associated
+    /// with it if the active mode is the provided one.
+    pub fn consume_if_mode_is<A: Clone>(
+        &mut self,
+        mode: ShortcutMode,
+        shortcuts: &[(Modifiers, Key, A)],
+    ) -> Option<A> {
+        for (modifier, key, action) in shortcuts {
+            if let Some(action) = self.add_shortcut_action_pair(*modifier, *key, action, mode) {
+                return Some(action.clone());
+            };
+        }
+        None
+    }
+
+    /// Activate a mode (see [`ShortcutModeStack`] for more).
+    #[inline]
+    pub fn activate_mode(&mut self, mode: ShortcutMode) {
+        if !self.mode_stack.is_active(mode) {
+            self.mode_stack.activate(mode);
+            self.enabled_shortcuts.clear();
+        }
+    }
+
+    /// Deactivate a mode, switching back to the previous layer (if any).
+    #[inline]
+    pub fn deactivate_mode(&mut self, mode: ShortcutMode) {
+        if self.mode_stack.is_active(mode) {
+            self.mode_stack.deactivate(mode);
+            self.enabled_shortcuts.clear();
+        }
+    }
+}
+
+/// Stack layers of keyboard shortcuts. Controls which shortcuts are active at any given time.
+///
+/// The first layer is the default layer, which is active when the user is in the main view.
+/// The second layer is active when the user is in a modal/dialog/window that needs full keyboard control.
+/// When the modal/dialog/window is closed the second layer is removed and the first layer is active again.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+struct ShortcutModeStack {
+    first: FirstLayerModes,
+    second: Option<SecondLayerModes>,
+}
+
+impl ShortcutModeStack {
+    fn is_active(&self, mode: ShortcutMode) -> bool {
+        match mode {
+            ShortcutMode::FirstLayer(first) => self.first == first && self.second.is_none(),
+            ShortcutMode::SecondLayer(second) => self.second == Some(second),
+        }
+    }
+
+    fn activate(&mut self, mode: ShortcutMode) {
+        match mode {
+            ShortcutMode::FirstLayer(first) => {
+                self.first = first;
+                self.second = None;
+            }
+            ShortcutMode::SecondLayer(second) => self.second = Some(second),
+        }
+    }
+
+    fn deactivate(&mut self, mode: ShortcutMode) {
+        match mode {
+            ShortcutMode::FirstLayer(first) => {
+                if self.first == first {
+                    self.first = FirstLayerModes::default();
+                }
+            }
+            ShortcutMode::SecondLayer(second) => {
+                if self.second == Some(second) {
+                    self.second = None;
+                }
+            }
+        }
+    }
+}
+
+/// Layers of keyboard shortcuts. See [`ShortcutModeStack`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ShortcutMode {
+    FirstLayer(FirstLayerModes),
+    SecondLayer(SecondLayerModes),
+}
+
+impl ShortcutMode {
+    #[inline]
+    pub fn composition() -> Self {
+        Self::FirstLayer(FirstLayerModes::Composition)
+    }
+
+    #[inline]
+    pub fn valve_control() -> Self {
+        Self::SecondLayer(SecondLayerModes::ValveControl)
+    }
+}
+
+/// First layer of keyboard shortcuts.
+///
+/// Active when the user is on the main view choosing how to customize their layout.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
+pub enum FirstLayerModes {
+    /// Shortcuts that are active when the user is in the main menu.
+    #[default]
+    Composition,
+}
+
+/// Second layer of keyboard shortcuts, sits on top of the first layer.
+///
+/// Active when the user is in a modal, dialog or window that needs full keyboard control.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SecondLayerModes {
+    /// Shortcuts that are active when the user is in the main menu.
+    ValveControl,
 }
diff --git a/src/ui/utils.rs b/src/ui/utils.rs
index 2469c54..7d1b193 100644
--- a/src/ui/utils.rs
+++ b/src/ui/utils.rs
@@ -1,16 +1,19 @@
 use egui::containers::Frame;
 use egui::{Response, Shadow, Stroke, Style, Ui};
 
-use super::panes::{Pane, PaneBehavior};
+use super::{
+    panes::{Pane, PaneBehavior},
+    shortcuts::ShortcutHandler,
+};
 
 /// This function wraps a ui into a popup frame intended for the pane that needs
 /// to be maximized on screen.
-pub fn maximized_pane_ui(ui: &mut Ui, pane: &mut Pane) {
+pub fn maximized_pane_ui(ui: &mut Ui, pane: &mut Pane, shortcut_handler: &mut ShortcutHandler) {
     Frame::popup(&Style::default())
         .fill(egui::Color32::TRANSPARENT)
         .shadow(Shadow::NONE)
         .stroke(Stroke::NONE)
-        .show(ui, |ui| pane.ui(ui));
+        .show(ui, |ui| pane.ui(ui, shortcut_handler));
 }
 
 #[derive(Debug, Default, Clone)]
-- 
GitLab


From 308375d706dfefc28ae6040aa4759768017e02a1 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Mon, 24 Mar 2025 15:00:59 +0100
Subject: [PATCH 15/24] CHECKPOINT

added wiggle icon
---
 icons/valve_control/dark/wiggle.svg  |  45 +++++++++++
 icons/valve_control/light/wiggle.svg |  45 +++++++++++
 src/ui/panes/valve_control.rs        | 111 ++++++++++++++++++---------
 src/ui/panes/valve_control/icons.rs  |  18 +++--
 4 files changed, 177 insertions(+), 42 deletions(-)
 create mode 100644 icons/valve_control/dark/wiggle.svg
 create mode 100644 icons/valve_control/light/wiggle.svg

diff --git a/icons/valve_control/dark/wiggle.svg b/icons/valve_control/dark/wiggle.svg
new file mode 100644
index 0000000..f033a58
--- /dev/null
+++ b/icons/valve_control/dark/wiggle.svg
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+
+<svg
+   width="800px"
+   height="800px"
+   viewBox="0 0 24 24"
+   fill="none"
+   version="1.1"
+   id="svg1"
+   sodipodi:docname="rotate-svgrepo-com.svg"
+   inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs1" />
+  <sodipodi:namedview
+     id="namedview1"
+     pagecolor="#505050"
+     bordercolor="#eeeeee"
+     borderopacity="1"
+     inkscape:showpageshadow="0"
+     inkscape:pageopacity="0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#505050"
+     inkscape:zoom="0.26520082"
+     inkscape:cx="350.67764"
+     inkscape:cy="444.94583"
+     inkscape:window-width="1104"
+     inkscape:window-height="847"
+     inkscape:window-x="0"
+     inkscape:window-y="25"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg1" />
+  <path
+     d="M4.06189 13C4.02104 12.6724 4 12.3387 4 12C4 7.58172 7.58172 4 12 4C14.5006 4 16.7332 5.14727 18.2002 6.94416M19.9381 11C19.979 11.3276 20 11.6613 20 12C20 16.4183 16.4183 20 12 20C9.61061 20 7.46589 18.9525 6 17.2916M9 17H6V17.2916M18.2002 4V6.94416M18.2002 6.94416V6.99993L15.2002 7M6 20V17.2916"
+     stroke="#000000"
+     stroke-width="2"
+     stroke-linecap="round"
+     stroke-linejoin="round"
+     id="path1"
+     style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#8c8c8c;stroke-opacity:1" />
+</svg>
diff --git a/icons/valve_control/light/wiggle.svg b/icons/valve_control/light/wiggle.svg
new file mode 100644
index 0000000..864577e
--- /dev/null
+++ b/icons/valve_control/light/wiggle.svg
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+
+<svg
+   width="800px"
+   height="800px"
+   viewBox="0 0 24 24"
+   fill="none"
+   version="1.1"
+   id="svg1"
+   sodipodi:docname="wiggle.svg"
+   inkscape:version="1.4 (e7c3feb1, 2024-10-09)"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs1" />
+  <sodipodi:namedview
+     id="namedview1"
+     pagecolor="#505050"
+     bordercolor="#eeeeee"
+     borderopacity="1"
+     inkscape:showpageshadow="0"
+     inkscape:pageopacity="0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#505050"
+     inkscape:zoom="0.26520082"
+     inkscape:cx="350.67765"
+     inkscape:cy="444.94583"
+     inkscape:window-width="1104"
+     inkscape:window-height="847"
+     inkscape:window-x="0"
+     inkscape:window-y="25"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg1" />
+  <path
+     d="M4.06189 13C4.02104 12.6724 4 12.3387 4 12C4 7.58172 7.58172 4 12 4C14.5006 4 16.7332 5.14727 18.2002 6.94416M19.9381 11C19.979 11.3276 20 11.6613 20 12C20 16.4183 16.4183 20 12 20C9.61061 20 7.46589 18.9525 6 17.2916M9 17H6V17.2916M18.2002 4V6.94416M18.2002 6.94416V6.99993L15.2002 7M6 20V17.2916"
+     stroke="#000000"
+     stroke-width="2"
+     stroke-linecap="round"
+     stroke-linejoin="round"
+     id="path1"
+     style="fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#505050;stroke-opacity:1" />
+</svg>
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 6564ae3..d1971b6 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -22,6 +22,7 @@ use strum::IntoEnumIterator;
 use tracing::{error, info, warn};
 
 use crate::{
+    error::ErrInstrument,
     mavlink::{MavMessage, TimedMessage},
     ui::{
         app::PaneResponse,
@@ -87,6 +88,7 @@ impl Default for ValveControlPane {
 }
 
 impl PaneBehavior for ValveControlPane {
+    #[profiling::function]
     fn ui(&mut self, ui: &mut Ui, shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
         let mut pane_response = PaneResponse::default();
 
@@ -152,6 +154,7 @@ impl PaneBehavior for ValveControlPane {
         pane_response
     }
 
+    #[profiling::function]
     fn get_message_subscriptions(&self) -> Box<dyn Iterator<Item = u32>> {
         let mut subscriptions = vec![];
         if self.needs_refresh() {
@@ -169,6 +172,7 @@ impl PaneBehavior for ValveControlPane {
         Box::new(subscriptions.into_iter())
     }
 
+    #[profiling::function]
     fn update(&mut self, messages: &[&TimedMessage]) {
         if self.needs_refresh() {
             // TODO
@@ -192,6 +196,7 @@ impl PaneBehavior for ValveControlPane {
         self.reset_last_refresh();
     }
 
+    #[profiling::function]
     fn drain_outgoing_messages(&mut self) -> Vec<MavMessage> {
         let mut outgoing = vec![];
 
@@ -213,6 +218,7 @@ const BTN_MAX_WIDTH: f32 = 125.;
 impl ValveControlPane {
     fn pane_ui(&mut self) -> impl FnOnce(&mut Ui) {
         |ui| {
+            profiling::function_scope!("pane_ui");
             ui.set_min_width(BTN_MAX_WIDTH);
             let n = (ui.max_rect().width() / BTN_MAX_WIDTH) as usize;
             let valve_chunks = SYMBOL_LIST.chars().zip(Valve::iter()).chunks(n.max(1));
@@ -232,6 +238,7 @@ impl ValveControlPane {
 
     fn menu_ui(&mut self) -> impl FnOnce(&mut Ui) {
         |ui| {
+            profiling::function_scope!("menu_ui");
             if ui.button("Refresh now").clicked() {
                 self.manual_refresh = true;
                 ui.close_menu();
@@ -245,6 +252,7 @@ impl ValveControlPane {
 
     fn settings_window_ui(auto_refresh_setting: &mut Option<Duration>) -> impl FnOnce(&mut Ui) {
         |ui| {
+            profiling::function_scope!("settings_window_ui");
             // Display auto refresh setting
             let mut auto_refresh = auto_refresh_setting.is_some();
             ui.horizontal(|ui| {
@@ -271,6 +279,7 @@ impl ValveControlPane {
 
     fn valve_frame_ui(&self, valve: Valve, symbol: char) -> impl FnOnce(&mut Ui) {
         move |ui| {
+            profiling::function_scope!("valve_frame_ui");
             let valve_str = valve.to_string();
             let timing = self.valves_state.get_timing_for(valve);
             let aperture = self.valves_state.get_aperture_for(valve);
@@ -308,34 +317,6 @@ impl ValveControlPane {
                 .ui(ui);
             };
 
-            fn big_number_ui(
-                response: &Response,
-                symbol: char,
-                text_color: Color32,
-            ) -> impl Fn(&mut Ui) {
-                move |ui: &mut Ui| {
-                    let visuals = ui.style().interact(response);
-                    let number = RichText::new(symbol.to_string())
-                        .color(text_color)
-                        .font(FontId::monospace(20.));
-
-                    let fill_color = if response.hovered() {
-                        visuals.bg_fill.gamma_multiply(0.8).to_opaque()
-                    } else {
-                        visuals.bg_fill
-                    };
-
-                    Frame::canvas(ui.style())
-                        .fill(fill_color)
-                        .stroke(Stroke::NONE)
-                        .inner_margin(Margin::same(5))
-                        .corner_radius(visuals.corner_radius)
-                        .show(ui, |ui| {
-                            Label::new(number).selectable(false).ui(ui);
-                        });
-                }
-            }
-
             let labels_ui = |ui: &mut Ui| {
                 let icon_size = Vec2::splat(17.);
                 let text_format = TextFormat {
@@ -377,9 +358,9 @@ impl ValveControlPane {
             };
 
             fn inside_frame(
-                response: &Response,
                 valve_title_ui: impl FnOnce(&mut Ui),
                 symbol: char,
+                btn_fill_color: Color32,
                 text_color: Color32,
                 labels_ui: impl FnOnce(&mut Ui),
             ) -> impl FnOnce(&mut Ui) {
@@ -387,7 +368,7 @@ impl ValveControlPane {
                     ui.vertical(|ui| {
                         valve_title_ui(ui);
                         ui.horizontal(|ui| {
-                            big_number_ui(response, symbol, text_color)(ui);
+                            big_symbol_ui(symbol, btn_fill_color, text_color)(ui);
                             labels_ui(ui);
                         });
                     });
@@ -408,6 +389,12 @@ impl ValveControlPane {
                         visuals.bg_fill.gamma_multiply(0.3)
                     };
 
+                    let btn_fill_color = if response.hovered() {
+                        visuals.bg_fill.gamma_multiply(0.8).to_opaque()
+                    } else {
+                        visuals.bg_fill
+                    };
+
                     Frame::canvas(ui.style())
                         .fill(fill_color)
                         .stroke(Stroke::NONE)
@@ -416,9 +403,9 @@ impl ValveControlPane {
                         .show(
                             ui,
                             inside_frame(
-                                &response,
                                 &valve_title_ui,
                                 symbol,
+                                btn_fill_color,
                                 text_color,
                                 &labels_ui,
                             ),
@@ -442,11 +429,13 @@ impl ValveControlPane {
         action: Option<PaneAction>,
     ) -> impl FnOnce(&mut Ui) {
         move |ui| {
+            profiling::function_scope!("valve_control_window_ui");
             let icon_size = Vec2::splat(25.);
             let text_size = 16.;
 
             fn btn_ui<R>(
                 valve: Valve,
+                key: Key,
                 add_contents: impl FnOnce(&mut Ui) -> R,
             ) -> impl FnOnce(&mut Ui) -> Response {
                 move |ui| {
@@ -455,7 +444,7 @@ impl ValveControlPane {
                         .corner_radius(ui.visuals().noninteractive().corner_radius);
 
                     wiggle_btn = ui.ctx().input(|input| {
-                        if input.key_down(ValveControlPane::WIGGLE_KEY) {
+                        if input.key_down(key) {
                             wiggle_btn
                                 .fill(ui.visuals().widgets.active.bg_fill)
                                 .stroke(ui.visuals().widgets.active.fg_stroke)
@@ -471,16 +460,28 @@ impl ValveControlPane {
                             .id_salt(format!("valve_control_window_{}_wiggle", valve))
                             .sense(Sense::click()),
                         |ui| {
-                            wiggle_btn.show(ui, |ui| ui.horizontal(|ui| add_contents(ui)));
+                            wiggle_btn.show(ui, |ui| {
+                                ui.set_width(200.);
+                                ui.horizontal(|ui| add_contents(ui))
+                            });
                         },
                     )
                     .response
                 }
             }
 
-            let wiggle_btn_response = btn_ui(valve, |ui| {
+            let wiggle_btn_response = btn_ui(valve, Self::WIGGLE_KEY, |ui| {
+                big_symbol_ui(
+                    Self::WIGGLE_KEY
+                        .symbol_or_name()
+                        .chars()
+                        .next()
+                        .log_unwrap(),
+                    ui.visuals().widgets.inactive.bg_fill,
+                    ui.visuals().text_color(),
+                )(ui);
                 ui.add(
-                    Icon::Aperture
+                    Icon::Wiggle
                         .as_image(ui.ctx().theme())
                         .fit_to_exact_size(icon_size),
                 );
@@ -488,7 +489,16 @@ impl ValveControlPane {
             })(ui);
 
             let mut aperture = 0_u32;
-            let aperture_btn_response = btn_ui(valve, |ui| {
+            let aperture_btn_response = btn_ui(valve, Self::APERTURE_KEY, |ui| {
+                big_symbol_ui(
+                    Self::APERTURE_KEY
+                        .symbol_or_name()
+                        .chars()
+                        .next()
+                        .log_unwrap(),
+                    ui.visuals().widgets.inactive.bg_fill,
+                    ui.visuals().text_color(),
+                )(ui);
                 ui.add(
                     Icon::Aperture
                         .as_image(ui.ctx().theme())
@@ -506,7 +516,16 @@ impl ValveControlPane {
             })(ui);
 
             let mut timing_ms = 0_u32;
-            let timing_btn_response = btn_ui(valve, |ui| {
+            let timing_btn_response = btn_ui(valve, Self::TIMING_KEY, |ui| {
+                big_symbol_ui(
+                    Self::TIMING_KEY
+                        .symbol_or_name()
+                        .chars()
+                        .next()
+                        .log_unwrap(),
+                    ui.visuals().widgets.inactive.bg_fill,
+                    ui.visuals().text_color(),
+                )(ui);
                 ui.add(
                     Icon::Timing
                         .as_image(ui.ctx().theme())
@@ -532,6 +551,7 @@ impl ValveControlPane {
         }
     }
 
+    #[profiling::function]
     fn keyboard_actions(&self, shortcut_handler: &mut ShortcutHandler) -> Option<PaneAction> {
         let mut key_action_pairs = Vec::new();
         match self
@@ -627,6 +647,23 @@ impl ValveControlPane {
     }
 }
 
+fn big_symbol_ui(symbol: char, fill_color: Color32, text_color: Color32) -> impl Fn(&mut Ui) {
+    move |ui: &mut Ui| {
+        let number = RichText::new(symbol.to_string())
+            .color(text_color)
+            .font(FontId::monospace(20.));
+
+        Frame::canvas(ui.style())
+            .fill(fill_color)
+            .stroke(Stroke::NONE)
+            .inner_margin(Margin::same(5))
+            .corner_radius(ui.visuals().widgets.noninteractive.corner_radius)
+            .show(ui, |ui| {
+                Label::new(number).selectable(false).ui(ui);
+            });
+    }
+}
+
 // ┌───────────────────────────┐
 // │       UTILS METHODS       │
 // └───────────────────────────┘
diff --git a/src/ui/panes/valve_control/icons.rs b/src/ui/panes/valve_control/icons.rs
index f8d70a9..b8b14e4 100644
--- a/src/ui/panes/valve_control/icons.rs
+++ b/src/ui/panes/valve_control/icons.rs
@@ -7,6 +7,7 @@ use crate::error::ErrInstrument;
 
 #[derive(Debug, Clone, Copy, EnumIter)]
 pub enum Icon {
+    Wiggle,
     Aperture,
     Timing,
 }
@@ -14,6 +15,18 @@ pub enum Icon {
 impl Icon {
     fn as_image_source(&self, theme: Theme) -> ImageSource {
         match (&self, theme) {
+            (Icon::Wiggle, Theme::Light) => {
+                egui::include_image!(concat!(
+                    env!("CARGO_MANIFEST_DIR"),
+                    "/icons/valve_control/light/wiggle.svg"
+                ))
+            }
+            (Icon::Wiggle, Theme::Dark) => {
+                egui::include_image!(concat!(
+                    env!("CARGO_MANIFEST_DIR"),
+                    "/icons/valve_control/dark/wiggle.svg"
+                ))
+            }
             (Icon::Aperture, Theme::Light) => {
                 egui::include_image!(concat!(
                     env!("CARGO_MANIFEST_DIR"),
@@ -56,9 +69,4 @@ impl Icon {
     pub fn as_image(&self, theme: Theme) -> Image {
         Image::new(self.as_image_source(theme))
     }
-
-    pub fn reset_cache(&self, ui: &mut Ui) {
-        let img: Image = self.as_image(ui.ctx().theme());
-        ui.ctx().forget_image(img.uri().log_unwrap());
-    }
 }
-- 
GitLab


From 14da876e3ce26ce03ec5c620321d4fadaaad9896 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Mon, 24 Mar 2025 15:27:54 +0100
Subject: [PATCH 16/24] CHECKPOINT

moved shortcut card (old big_symbol_ui to ui.rs custom widget
ShortcutCard)
---
 src/ui/panes/valve_control.rs       | 155 +++++++++++-----------------
 src/ui/panes/valve_control/icons.rs |   4 +-
 src/ui/panes/valve_control/ui.rs    |  66 ++++++++++++
 3 files changed, 128 insertions(+), 97 deletions(-)
 create mode 100644 src/ui/panes/valve_control/ui.rs

diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index d1971b6..618e29e 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -1,5 +1,6 @@
 mod commands;
 mod icons;
+mod ui;
 mod valves;
 
 use std::{
@@ -8,9 +9,9 @@ use std::{
 };
 
 use egui::{
-    Color32, DragValue, FontId, Frame, Grid, Key, Label, Margin, Modal, Modifiers, Response,
-    RichText, Sense, Stroke, TextFormat, Ui, UiBuilder, Vec2, Widget, Window, text::LayoutJob,
-    vec2,
+    Color32, DragValue, FontId, Frame, Grid, Key, KeyboardShortcut, Label, Modal, Modifiers,
+    Response, RichText, Sense, Stroke, TextFormat, Ui, UiBuilder, Vec2, Widget, Window,
+    text::LayoutJob, vec2,
 };
 use itertools::Itertools;
 use serde::{Deserialize, Serialize};
@@ -20,9 +21,9 @@ use skyward_mavlink::{
 };
 use strum::IntoEnumIterator;
 use tracing::{error, info, warn};
+use ui::ShortcutCard;
 
 use crate::{
-    error::ErrInstrument,
     mavlink::{MavMessage, TimedMessage},
     ui::{
         app::PaneResponse,
@@ -228,7 +229,7 @@ impl ValveControlPane {
                 .show(ui, |ui| {
                     for chunk in &valve_chunks {
                         for (symbol, valve) in chunk {
-                            ui.scope(self.valve_frame_ui(valve, symbol));
+                            ui.scope(self.valve_frame_ui(valve, map_symbol_to_key(symbol)));
                         }
                         ui.end_row();
                     }
@@ -277,7 +278,7 @@ impl ValveControlPane {
         }
     }
 
-    fn valve_frame_ui(&self, valve: Valve, symbol: char) -> impl FnOnce(&mut Ui) {
+    fn valve_frame_ui(&self, valve: Valve, shortcut_key: Key) -> impl FnOnce(&mut Ui) {
         move |ui| {
             profiling::function_scope!("valve_frame_ui");
             let valve_str = valve.to_string();
@@ -357,24 +358,6 @@ impl ValveControlPane {
                 });
             };
 
-            fn inside_frame(
-                valve_title_ui: impl FnOnce(&mut Ui),
-                symbol: char,
-                btn_fill_color: Color32,
-                text_color: Color32,
-                labels_ui: impl FnOnce(&mut Ui),
-            ) -> impl FnOnce(&mut Ui) {
-                move |ui: &mut Ui| {
-                    ui.vertical(|ui| {
-                        valve_title_ui(ui);
-                        ui.horizontal(|ui| {
-                            big_symbol_ui(symbol, btn_fill_color, text_color)(ui);
-                            labels_ui(ui);
-                        });
-                    });
-                }
-            }
-
             ui.scope_builder(
                 UiBuilder::new()
                     .id_salt("valve_".to_owned() + &valve_str)
@@ -395,21 +378,26 @@ impl ValveControlPane {
                         visuals.bg_fill
                     };
 
+                    let inside_frame = |ui: &mut Ui| {
+                        ui.vertical(|ui| {
+                            valve_title_ui(ui);
+                            ui.horizontal(|ui| {
+                                ShortcutCard::new(map_key_to_shortcut(shortcut_key))
+                                    .text_color(text_color)
+                                    .fill_color(btn_fill_color)
+                                    .text_size(20.)
+                                    .ui(ui);
+                                labels_ui(ui);
+                            });
+                        });
+                    };
+
                     Frame::canvas(ui.style())
                         .fill(fill_color)
                         .stroke(Stroke::NONE)
                         .inner_margin(ui.spacing().menu_margin)
                         .corner_radius(visuals.corner_radius)
-                        .show(
-                            ui,
-                            inside_frame(
-                                &valve_title_ui,
-                                symbol,
-                                btn_fill_color,
-                                text_color,
-                                &labels_ui,
-                            ),
-                        );
+                        .show(ui, inside_frame);
 
                     if response.clicked() {
                         info!("Clicked!");
@@ -471,15 +459,11 @@ impl ValveControlPane {
             }
 
             let wiggle_btn_response = btn_ui(valve, Self::WIGGLE_KEY, |ui| {
-                big_symbol_ui(
-                    Self::WIGGLE_KEY
-                        .symbol_or_name()
-                        .chars()
-                        .next()
-                        .log_unwrap(),
-                    ui.visuals().widgets.inactive.bg_fill,
-                    ui.visuals().text_color(),
-                )(ui);
+                ShortcutCard::new(map_key_to_shortcut(Self::WIGGLE_KEY))
+                    .text_color(ui.visuals().text_color())
+                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
+                    .text_size(20.)
+                    .ui(ui);
                 ui.add(
                     Icon::Wiggle
                         .as_image(ui.ctx().theme())
@@ -490,15 +474,11 @@ impl ValveControlPane {
 
             let mut aperture = 0_u32;
             let aperture_btn_response = btn_ui(valve, Self::APERTURE_KEY, |ui| {
-                big_symbol_ui(
-                    Self::APERTURE_KEY
-                        .symbol_or_name()
-                        .chars()
-                        .next()
-                        .log_unwrap(),
-                    ui.visuals().widgets.inactive.bg_fill,
-                    ui.visuals().text_color(),
-                )(ui);
+                ShortcutCard::new(map_key_to_shortcut(Self::APERTURE_KEY))
+                    .text_color(ui.visuals().text_color())
+                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
+                    .text_size(20.)
+                    .ui(ui);
                 ui.add(
                     Icon::Aperture
                         .as_image(ui.ctx().theme())
@@ -517,15 +497,11 @@ impl ValveControlPane {
 
             let mut timing_ms = 0_u32;
             let timing_btn_response = btn_ui(valve, Self::TIMING_KEY, |ui| {
-                big_symbol_ui(
-                    Self::TIMING_KEY
-                        .symbol_or_name()
-                        .chars()
-                        .next()
-                        .log_unwrap(),
-                    ui.visuals().widgets.inactive.bg_fill,
-                    ui.visuals().text_color(),
-                )(ui);
+                ShortcutCard::new(map_key_to_shortcut(Self::TIMING_KEY))
+                    .text_color(ui.visuals().text_color())
+                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
+                    .text_size(20.)
+                    .ui(ui);
                 ui.add(
                     Icon::Timing
                         .as_image(ui.ctx().theme())
@@ -616,27 +592,9 @@ impl ValveControlPane {
                 shortcut_handler.deactivate_mode(ShortcutMode::valve_control());
                 // No window is open, so we can map the keys to open the valve control windows
                 for &symbol in self.valve_symbol_map.keys() {
-                    let key = match symbol {
-                        '1' => Key::Num1,
-                        '2' => Key::Num2,
-                        '3' => Key::Num3,
-                        '4' => Key::Num4,
-                        '5' => Key::Num5,
-                        '6' => Key::Num6,
-                        '7' => Key::Num7,
-                        '8' => Key::Num8,
-                        '9' => Key::Num9,
-                        '-' => Key::Minus,
-                        '/' => Key::Slash,
-                        '.' => Key::Period,
-                        _ => {
-                            error!("Invalid symbol: {}", symbol);
-                            panic!("Invalid symbol: {}", symbol);
-                        }
-                    };
                     key_action_pairs.push((
                         Modifiers::NONE,
-                        key,
+                        map_symbol_to_key(symbol),
                         PaneAction::OpenValveControl(self.valve_symbol_map[&symbol]),
                     ));
                 }
@@ -647,23 +605,32 @@ impl ValveControlPane {
     }
 }
 
-fn big_symbol_ui(symbol: char, fill_color: Color32, text_color: Color32) -> impl Fn(&mut Ui) {
-    move |ui: &mut Ui| {
-        let number = RichText::new(symbol.to_string())
-            .color(text_color)
-            .font(FontId::monospace(20.));
-
-        Frame::canvas(ui.style())
-            .fill(fill_color)
-            .stroke(Stroke::NONE)
-            .inner_margin(Margin::same(5))
-            .corner_radius(ui.visuals().widgets.noninteractive.corner_radius)
-            .show(ui, |ui| {
-                Label::new(number).selectable(false).ui(ui);
-            });
+fn map_symbol_to_key(symbol: char) -> Key {
+    match symbol {
+        '1' => Key::Num1,
+        '2' => Key::Num2,
+        '3' => Key::Num3,
+        '4' => Key::Num4,
+        '5' => Key::Num5,
+        '6' => Key::Num6,
+        '7' => Key::Num7,
+        '8' => Key::Num8,
+        '9' => Key::Num9,
+        '-' => Key::Minus,
+        '/' => Key::Slash,
+        '.' => Key::Period,
+        _ => {
+            error!("Invalid symbol: {}", symbol);
+            panic!("Invalid symbol: {}", symbol)
+        }
     }
 }
 
+#[inline]
+fn map_key_to_shortcut(key: Key) -> KeyboardShortcut {
+    KeyboardShortcut::new(Modifiers::NONE, key)
+}
+
 // ┌───────────────────────────┐
 // │       UTILS METHODS       │
 // └───────────────────────────┘
diff --git a/src/ui/panes/valve_control/icons.rs b/src/ui/panes/valve_control/icons.rs
index b8b14e4..654acae 100644
--- a/src/ui/panes/valve_control/icons.rs
+++ b/src/ui/panes/valve_control/icons.rs
@@ -1,10 +1,8 @@
-use egui::{Context, Image, ImageSource, SizeHint, TextureOptions, Theme, Ui};
+use egui::{Context, Image, ImageSource, SizeHint, TextureOptions, Theme};
 use strum::IntoEnumIterator;
 use strum_macros::EnumIter;
 use tracing::error;
 
-use crate::error::ErrInstrument;
-
 #[derive(Debug, Clone, Copy, EnumIter)]
 pub enum Icon {
     Wiggle,
diff --git a/src/ui/panes/valve_control/ui.rs b/src/ui/panes/valve_control/ui.rs
new file mode 100644
index 0000000..9bbc056
--- /dev/null
+++ b/src/ui/panes/valve_control/ui.rs
@@ -0,0 +1,66 @@
+use egui::{
+    Color32, FontId, Frame, KeyboardShortcut, Label, Margin, ModifierNames, RichText, Stroke,
+    Widget,
+};
+
+pub struct ShortcutCard {
+    shortcut: KeyboardShortcut,
+    text_size: f32,
+    text_color: Option<Color32>,
+    fill_color: Option<Color32>,
+}
+
+impl Widget for ShortcutCard {
+    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
+        #[cfg(target_os = "macos")]
+        let is_mac = true;
+        #[cfg(not(target_os = "macos"))]
+        let is_mac = false;
+
+        let shortcut_fmt = self.shortcut.format(&ModifierNames::SYMBOLS, is_mac);
+        let default_style = ui.style().noninteractive();
+        let text_color = self.text_color.unwrap_or(default_style.text_color());
+        let fill_color = self.fill_color.unwrap_or(default_style.bg_fill);
+        let corner_radius = default_style.corner_radius;
+
+        let number = RichText::new(shortcut_fmt)
+            .color(text_color)
+            .font(FontId::monospace(self.text_size));
+
+        Frame::canvas(ui.style())
+            .fill(fill_color)
+            .stroke(Stroke::NONE)
+            .inner_margin(Margin::same(5))
+            .corner_radius(corner_radius)
+            .show(ui, |ui| {
+                Label::new(number).selectable(false).ui(ui);
+            })
+            .response
+    }
+}
+
+impl ShortcutCard {
+    pub fn new(shortcut: KeyboardShortcut) -> Self {
+        Self {
+            shortcut,
+            text_size: 20.,
+            text_color: None,
+            fill_color: None,
+        }
+    }
+
+    pub fn text_size(mut self, text_size: f32) -> Self {
+        self.text_size = text_size;
+        self
+    }
+
+    pub fn text_color(mut self, text_color: Color32) -> Self {
+        self.text_color = Some(text_color);
+        self
+    }
+
+    pub fn fill_color(mut self, fill_color: Color32) -> Self {
+        self.fill_color = Some(fill_color);
+        self
+    }
+}
-- 
GitLab


From 0c590eca6090b561103c022b4c6af007bea0d3c2 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Mon, 24 Mar 2025 16:17:31 +0100
Subject: [PATCH 17/24] CHECKPOINT

connected click and shortcuts in opening windows
---
 src/ui/panes/valve_control.rs | 158 ++++++++++++++++++++--------------
 1 file changed, 94 insertions(+), 64 deletions(-)

diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 618e29e..6a6e152 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -20,7 +20,7 @@ use skyward_mavlink::{
     orion::{ACK_TM_DATA, NACK_TM_DATA, WACK_TM_DATA},
 };
 use strum::IntoEnumIterator;
-use tracing::{error, info, warn};
+use tracing::{info, trace, warn};
 use ui::ShortcutCard;
 
 use crate::{
@@ -40,6 +40,26 @@ use valves::{Valve, ValveStateManager};
 const DEFAULT_AUTO_REFRESH_RATE: Duration = Duration::from_secs(1);
 const SYMBOL_LIST: &str = "123456789-/.";
 
+fn map_symbol_to_key(symbol: char) -> Key {
+    match symbol {
+        '1' => Key::Num1,
+        '2' => Key::Num2,
+        '3' => Key::Num3,
+        '4' => Key::Num4,
+        '5' => Key::Num5,
+        '6' => Key::Num6,
+        '7' => Key::Num7,
+        '8' => Key::Num8,
+        '9' => Key::Num9,
+        '-' => Key::Minus,
+        '/' => Key::Slash,
+        '.' => Key::Period,
+        _ => {
+            unreachable!("Invalid symbol: {}", symbol);
+        }
+    }
+}
+
 #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
 pub struct ValveControlPane {
     // INTERNAL
@@ -63,7 +83,7 @@ pub struct ValveControlPane {
     #[serde(skip)]
     is_settings_window_open: bool,
     #[serde(skip)]
-    valve_symbol_map: HashMap<char, Valve>,
+    valve_key_map: HashMap<Valve, Key>,
     #[serde(skip)]
     valve_window_states: HashMap<Valve, ValveWindowState>,
 }
@@ -71,7 +91,9 @@ pub struct ValveControlPane {
 impl Default for ValveControlPane {
     fn default() -> Self {
         let symbols: Vec<char> = SYMBOL_LIST.chars().collect();
-        let valve_symbol = symbols.into_iter().zip(Valve::iter()).collect();
+        let valve_key_map = Valve::iter()
+            .zip(symbols.into_iter().map(map_symbol_to_key))
+            .collect();
         let valve_window_states = Valve::iter()
             .map(|v| (v, ValveWindowState::Closed))
             .collect();
@@ -82,7 +104,7 @@ impl Default for ValveControlPane {
             manual_refresh: false,
             last_refresh: None,
             is_settings_window_open: false,
-            valve_symbol_map: valve_symbol,
+            valve_key_map,
             valve_window_states,
         }
     }
@@ -146,6 +168,10 @@ impl PaneBehavior for ValveControlPane {
             .find(|&(_, state)| !state.is_closed())
             .map(|(&v, _)| v)
         {
+            trace!(
+                "Valve control window for valve {} is open",
+                valve_window_open
+            );
             Modal::new(ui.auto_id_with(format!("valve_control {}", valve_window_open))).show(
                 ui.ctx(),
                 self.valve_control_window_ui(valve_window_open, action_to_pass),
@@ -229,7 +255,15 @@ impl ValveControlPane {
                 .show(ui, |ui| {
                     for chunk in &valve_chunks {
                         for (symbol, valve) in chunk {
-                            ui.scope(self.valve_frame_ui(valve, map_symbol_to_key(symbol)));
+                            let response = ui
+                                .scope(self.valve_frame_ui(valve, map_symbol_to_key(symbol)))
+                                .inner;
+
+                            if response.clicked() {
+                                info!("Clicked on valve: {:?}", valve);
+                                self.valve_window_states
+                                    .insert(valve, ValveWindowState::Open);
+                            }
                         }
                         ui.end_row();
                     }
@@ -278,7 +312,7 @@ impl ValveControlPane {
         }
     }
 
-    fn valve_frame_ui(&self, valve: Valve, shortcut_key: Key) -> impl FnOnce(&mut Ui) {
+    fn valve_frame_ui(&self, valve: Valve, shortcut_key: Key) -> impl FnOnce(&mut Ui) -> Response {
         move |ui| {
             profiling::function_scope!("valve_frame_ui");
             let valve_str = valve.to_string();
@@ -364,19 +398,28 @@ impl ValveControlPane {
                     .sense(Sense::click()),
                 |ui| {
                     let response = ui.response();
+                    let shortcut_key_is_down = ui
+                        .ctx()
+                        .input(|input| input.key_down(self.valve_key_map[&valve]));
                     let visuals = ui.style().interact(&response);
 
-                    let fill_color = if response.hovered() {
-                        visuals.bg_fill
-                    } else {
-                        visuals.bg_fill.gamma_multiply(0.3)
-                    };
-
-                    let btn_fill_color = if response.hovered() {
-                        visuals.bg_fill.gamma_multiply(0.8).to_opaque()
-                    } else {
-                        visuals.bg_fill
-                    };
+                    let (fill_color, btn_fill_color, stroke) =
+                        if response.clicked() || shortcut_key_is_down {
+                            let visuals = ui.visuals().widgets.active;
+                            (visuals.bg_fill, visuals.bg_fill, visuals.bg_stroke)
+                        } else if response.hovered() {
+                            (
+                                visuals.bg_fill,
+                                visuals.bg_fill.gamma_multiply(0.8).to_opaque(),
+                                visuals.bg_stroke,
+                            )
+                        } else {
+                            (
+                                visuals.bg_fill.gamma_multiply(0.3),
+                                visuals.bg_fill,
+                                Stroke::new(1.0, Color32::TRANSPARENT),
+                            )
+                        };
 
                     let inside_frame = |ui: &mut Ui| {
                         ui.vertical(|ui| {
@@ -394,16 +437,13 @@ impl ValveControlPane {
 
                     Frame::canvas(ui.style())
                         .fill(fill_color)
-                        .stroke(Stroke::NONE)
+                        .stroke(stroke)
                         .inner_margin(ui.spacing().menu_margin)
                         .corner_radius(visuals.corner_radius)
                         .show(ui, inside_frame);
-
-                    if response.clicked() {
-                        info!("Clicked!");
-                    }
                 },
-            );
+            )
+            .response
         }
     }
 
@@ -427,31 +467,42 @@ impl ValveControlPane {
                 add_contents: impl FnOnce(&mut Ui) -> R,
             ) -> impl FnOnce(&mut Ui) -> Response {
                 move |ui| {
-                    let mut wiggle_btn = Frame::canvas(ui.style())
+                    let wiggle_btn = Frame::canvas(ui.style())
                         .inner_margin(ui.spacing().menu_margin)
                         .corner_radius(ui.visuals().noninteractive().corner_radius);
 
-                    wiggle_btn = ui.ctx().input(|input| {
-                        if input.key_down(key) {
-                            wiggle_btn
-                                .fill(ui.visuals().widgets.active.bg_fill)
-                                .stroke(ui.visuals().widgets.active.fg_stroke)
-                        } else {
-                            wiggle_btn
-                                .fill(ui.visuals().widgets.inactive.bg_fill.gamma_multiply(0.3))
-                                .stroke(Stroke::new(2.0, Color32::TRANSPARENT))
-                        }
-                    });
-
                     ui.scope_builder(
                         UiBuilder::new()
                             .id_salt(format!("valve_control_window_{}_wiggle", valve))
                             .sense(Sense::click()),
                         |ui| {
-                            wiggle_btn.show(ui, |ui| {
-                                ui.set_width(200.);
-                                ui.horizontal(|ui| add_contents(ui))
-                            });
+                            let response = ui.response();
+
+                            let clicked = response.clicked();
+                            let shortcut_down = ui.ctx().input(|input| input.key_down(key));
+
+                            let visuals = ui.style().interact(&response);
+                            let stroke = if shortcut_down || clicked {
+                                let visuals = ui.visuals().widgets.active;
+                                visuals.bg_stroke
+                            } else if response.hovered() {
+                                visuals.bg_stroke
+                            } else {
+                                Stroke::new(1., Color32::TRANSPARENT)
+                            };
+
+                            wiggle_btn
+                                .fill(visuals.bg_fill.gamma_multiply(0.3).to_opaque())
+                                .stroke(stroke)
+                                .stroke(stroke)
+                                .show(ui, |ui| {
+                                    ui.set_width(200.);
+                                    ui.horizontal(|ui| add_contents(ui))
+                                });
+
+                            if response.clicked() {
+                                info!("Clicked!");
+                            }
                         },
                     )
                     .response
@@ -591,11 +642,11 @@ impl ValveControlPane {
             None => {
                 shortcut_handler.deactivate_mode(ShortcutMode::valve_control());
                 // No window is open, so we can map the keys to open the valve control windows
-                for &symbol in self.valve_symbol_map.keys() {
+                for (&valve, &key) in self.valve_key_map.iter() {
                     key_action_pairs.push((
                         Modifiers::NONE,
-                        map_symbol_to_key(symbol),
-                        PaneAction::OpenValveControl(self.valve_symbol_map[&symbol]),
+                        key,
+                        PaneAction::OpenValveControl(valve),
                     ));
                 }
                 shortcut_handler
@@ -605,27 +656,6 @@ impl ValveControlPane {
     }
 }
 
-fn map_symbol_to_key(symbol: char) -> Key {
-    match symbol {
-        '1' => Key::Num1,
-        '2' => Key::Num2,
-        '3' => Key::Num3,
-        '4' => Key::Num4,
-        '5' => Key::Num5,
-        '6' => Key::Num6,
-        '7' => Key::Num7,
-        '8' => Key::Num8,
-        '9' => Key::Num9,
-        '-' => Key::Minus,
-        '/' => Key::Slash,
-        '.' => Key::Period,
-        _ => {
-            error!("Invalid symbol: {}", symbol);
-            panic!("Invalid symbol: {}", symbol)
-        }
-    }
-}
-
 #[inline]
 fn map_key_to_shortcut(key: Key) -> KeyboardShortcut {
     KeyboardShortcut::new(Modifiers::NONE, key)
-- 
GitLab


From bab943555e3592e22d3bdb8000749ae3861ac4b6 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Mon, 24 Mar 2025 16:27:48 +0100
Subject: [PATCH 18/24] fixed fill color and bad light theme adaptation

---
 src/ui/panes/valve_control.rs | 46 ++++++++++++++++++-----------------
 1 file changed, 24 insertions(+), 22 deletions(-)

diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 6a6e152..cfa1ffa 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -403,23 +403,24 @@ impl ValveControlPane {
                         .input(|input| input.key_down(self.valve_key_map[&valve]));
                     let visuals = ui.style().interact(&response);
 
-                    let (fill_color, btn_fill_color, stroke) =
-                        if response.clicked() || shortcut_key_is_down {
-                            let visuals = ui.visuals().widgets.active;
-                            (visuals.bg_fill, visuals.bg_fill, visuals.bg_stroke)
-                        } else if response.hovered() {
-                            (
-                                visuals.bg_fill,
-                                visuals.bg_fill.gamma_multiply(0.8).to_opaque(),
-                                visuals.bg_stroke,
-                            )
-                        } else {
-                            (
-                                visuals.bg_fill.gamma_multiply(0.3),
-                                visuals.bg_fill,
-                                Stroke::new(1.0, Color32::TRANSPARENT),
-                            )
-                        };
+                    let (fill_color, btn_fill_color, stroke) = if response.clicked()
+                        || shortcut_key_is_down && self.valve_window_states[&valve].is_closed()
+                    {
+                        let visuals = ui.visuals().widgets.active;
+                        (visuals.bg_fill, visuals.bg_fill, visuals.bg_stroke)
+                    } else if response.hovered() {
+                        (
+                            visuals.bg_fill,
+                            visuals.bg_fill.gamma_multiply(0.8).to_opaque(),
+                            visuals.bg_stroke,
+                        )
+                    } else {
+                        (
+                            visuals.bg_fill.gamma_multiply(0.3),
+                            visuals.bg_fill,
+                            Stroke::new(1.0, Color32::TRANSPARENT),
+                        )
+                    };
 
                     let inside_frame = |ui: &mut Ui| {
                         ui.vertical(|ui| {
@@ -482,17 +483,18 @@ impl ValveControlPane {
                             let shortcut_down = ui.ctx().input(|input| input.key_down(key));
 
                             let visuals = ui.style().interact(&response);
-                            let stroke = if shortcut_down || clicked {
+                            let (fill_color, stroke) = if shortcut_down || clicked {
                                 let visuals = ui.visuals().widgets.active;
-                                visuals.bg_stroke
+                                (visuals.bg_fill, visuals.bg_stroke)
                             } else if response.hovered() {
-                                visuals.bg_stroke
+                                (visuals.bg_fill, visuals.bg_stroke)
                             } else {
-                                Stroke::new(1., Color32::TRANSPARENT)
+                                let stroke = Stroke::new(1., Color32::TRANSPARENT);
+                                (visuals.bg_fill.gamma_multiply(0.3), stroke)
                             };
 
                             wiggle_btn
-                                .fill(visuals.bg_fill.gamma_multiply(0.3).to_opaque())
+                                .fill(fill_color)
                                 .stroke(stroke)
                                 .stroke(stroke)
                                 .show(ui, |ui| {
-- 
GitLab


From ced64b4e061ea6991a566a2f87716cf823879eb4 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Mon, 24 Mar 2025 17:56:47 +0100
Subject: [PATCH 19/24] CHECKPOINT

---
 src/ui/panes/valve_control.rs        | 134 ++++++++++++++++++---------
 src/ui/panes/valve_control/valves.rs |  10 ++
 2 files changed, 99 insertions(+), 45 deletions(-)

diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index cfa1ffa..793d004 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -139,15 +139,13 @@ impl PaneBehavior for ValveControlPane {
         match action_to_pass {
             // Open the valve control window if the action is to open it
             Some(PaneAction::OpenValveControl(valve)) => {
-                self.valve_window_states
-                    .insert(valve, ValveWindowState::Open);
+                self.set_window_state(valve, ValveWindowState::Open);
             }
             // Close if the user requests so
             Some(PaneAction::CloseValveControls) => {
                 warn!("closing all");
                 for valve in Valve::iter() {
-                    self.valve_window_states
-                        .insert(valve, ValveWindowState::Closed);
+                    self.set_window_state(valve, ValveWindowState::Closed);
                 }
             }
             // Ignore otherwise
@@ -261,8 +259,7 @@ impl ValveControlPane {
 
                             if response.clicked() {
                                 info!("Clicked on valve: {:?}", valve);
-                                self.valve_window_states
-                                    .insert(valve, ValveWindowState::Open);
+                                self.set_window_state(valve, ValveWindowState::Open);
                             }
                         }
                         ui.end_row();
@@ -398,13 +395,12 @@ impl ValveControlPane {
                     .sense(Sense::click()),
                 |ui| {
                     let response = ui.response();
-                    let shortcut_key_is_down = ui
-                        .ctx()
-                        .input(|input| input.key_down(self.valve_key_map[&valve]));
+                    let shortcut_key_is_down = ui.ctx().input(|input| input.key_down(shortcut_key));
                     let visuals = ui.style().interact(&response);
 
                     let (fill_color, btn_fill_color, stroke) = if response.clicked()
-                        || shortcut_key_is_down && self.valve_window_states[&valve].is_closed()
+                        || shortcut_key_is_down
+                            && self.valve_window_states.values().all(|&v| v.is_closed())
                     {
                         let visuals = ui.visuals().widgets.active;
                         (visuals.bg_fill, visuals.bg_fill, visuals.bg_stroke)
@@ -455,7 +451,7 @@ impl ValveControlPane {
     fn valve_control_window_ui(
         &mut self,
         valve: Valve,
-        action: Option<PaneAction>,
+        mut action: Option<PaneAction>,
     ) -> impl FnOnce(&mut Ui) {
         move |ui| {
             profiling::function_scope!("valve_control_window_ui");
@@ -463,7 +459,7 @@ impl ValveControlPane {
             let text_size = 16.;
 
             fn btn_ui<R>(
-                valve: Valve,
+                window_state: &ValveWindowState,
                 key: Key,
                 add_contents: impl FnOnce(&mut Ui) -> R,
             ) -> impl FnOnce(&mut Ui) -> Response {
@@ -472,18 +468,15 @@ impl ValveControlPane {
                         .inner_margin(ui.spacing().menu_margin)
                         .corner_radius(ui.visuals().noninteractive().corner_radius);
 
-                    ui.scope_builder(
-                        UiBuilder::new()
-                            .id_salt(format!("valve_control_window_{}_wiggle", valve))
-                            .sense(Sense::click()),
-                        |ui| {
-                            let response = ui.response();
+                    ui.scope_builder(UiBuilder::new().id_salt(key).sense(Sense::click()), |ui| {
+                        let response = ui.response();
 
-                            let clicked = response.clicked();
-                            let shortcut_down = ui.ctx().input(|input| input.key_down(key));
+                        let clicked = response.clicked();
+                        let shortcut_down = ui.ctx().input(|input| input.key_down(key));
 
-                            let visuals = ui.style().interact(&response);
-                            let (fill_color, stroke) = if shortcut_down || clicked {
+                        let visuals = ui.style().interact(&response);
+                        let (fill_color, stroke) =
+                            if clicked || shortcut_down && window_state.is_open() {
                                 let visuals = ui.visuals().widgets.active;
                                 (visuals.bg_fill, visuals.bg_stroke)
                             } else if response.hovered() {
@@ -493,25 +486,25 @@ impl ValveControlPane {
                                 (visuals.bg_fill.gamma_multiply(0.3), stroke)
                             };
 
-                            wiggle_btn
-                                .fill(fill_color)
-                                .stroke(stroke)
-                                .stroke(stroke)
-                                .show(ui, |ui| {
-                                    ui.set_width(200.);
-                                    ui.horizontal(|ui| add_contents(ui))
-                                });
+                        wiggle_btn
+                            .fill(fill_color)
+                            .stroke(stroke)
+                            .stroke(stroke)
+                            .show(ui, |ui| {
+                                ui.set_width(200.);
+                                ui.horizontal(|ui| add_contents(ui))
+                            });
 
-                            if response.clicked() {
-                                info!("Clicked!");
-                            }
-                        },
-                    )
+                        if response.clicked() {
+                            info!("Clicked!");
+                        }
+                    })
                     .response
                 }
             }
 
-            let wiggle_btn_response = btn_ui(valve, Self::WIGGLE_KEY, |ui| {
+            let window_state = &self.valve_window_states[&valve];
+            let wiggle_btn_response = btn_ui(window_state, Self::WIGGLE_KEY, |ui| {
                 ShortcutCard::new(map_key_to_shortcut(Self::WIGGLE_KEY))
                     .text_color(ui.visuals().text_color())
                     .fill_color(ui.visuals().widgets.inactive.bg_fill)
@@ -525,8 +518,8 @@ impl ValveControlPane {
                 ui.add(Label::new(RichText::new("Wiggle").size(text_size)).selectable(false));
             })(ui);
 
-            let mut aperture = 0_u32;
-            let aperture_btn_response = btn_ui(valve, Self::APERTURE_KEY, |ui| {
+            let mut aperture = self.valves_state.get_aperture_for(valve).valid_or(0.5) * 100.;
+            let aperture_btn_response = btn_ui(window_state, Self::APERTURE_KEY, |ui| {
                 ShortcutCard::new(map_key_to_shortcut(Self::APERTURE_KEY))
                     .text_color(ui.visuals().text_color())
                     .fill_color(ui.visuals().widgets.inactive.bg_fill)
@@ -538,18 +531,24 @@ impl ValveControlPane {
                         .fit_to_exact_size(icon_size),
                 );
                 ui.add(Label::new(RichText::new("Aperture: ").size(text_size)).selectable(false));
+                let drag_value_id = ui.next_auto_id();
                 ui.add(
                     DragValue::new(&mut aperture)
                         .speed(0.5)
                         .range(0.0..=100.0)
-                        .fixed_decimals(1)
+                        .fixed_decimals(0)
                         .update_while_editing(false)
                         .suffix("%"),
                 );
+                if matches!(window_state, ValveWindowState::ApertureFocused) {
+                    ui.ctx().memory_mut(|m| {
+                        m.request_focus(drag_value_id);
+                    });
+                }
             })(ui);
 
             let mut timing_ms = 0_u32;
-            let timing_btn_response = btn_ui(valve, Self::TIMING_KEY, |ui| {
+            let timing_btn_response = btn_ui(window_state, Self::TIMING_KEY, |ui| {
                 ShortcutCard::new(map_key_to_shortcut(Self::TIMING_KEY))
                     .text_color(ui.visuals().text_color())
                     .fill_color(ui.visuals().widgets.inactive.bg_fill)
@@ -561,6 +560,7 @@ impl ValveControlPane {
                         .fit_to_exact_size(icon_size),
                 );
                 ui.add(Label::new(RichText::new("Timing: ").size(text_size)).selectable(false));
+                let drag_value_id = ui.next_auto_id();
                 ui.add(
                     DragValue::new(&mut timing_ms)
                         .speed(1)
@@ -569,14 +569,53 @@ impl ValveControlPane {
                         .update_while_editing(false)
                         .suffix(" [ms]"),
                 );
+                if matches!(window_state, ValveWindowState::TimingFocused) {
+                    ui.ctx().memory_mut(|m| {
+                        m.request_focus(drag_value_id);
+                    });
+                }
             })(ui);
 
-            if wiggle_btn_response.clicked() || matches!(action, Some(PaneAction::Wiggle)) {
-                info!("Wiggle valve: {:?}", valve);
-                self.commands.push(Command::wiggle(valve).into());
+            // consider that action may be different that null if a keyboard shortcut was captured
+            if wiggle_btn_response.clicked() {
+                action = Some(PaneAction::Wiggle);
+            } else if aperture_btn_response.clicked() {
+                action = Some(PaneAction::SetAperture);
+            } else if timing_btn_response.clicked() {
+                action = Some(PaneAction::SetTiming);
+            }
+
+            match action {
+                Some(PaneAction::Wiggle) => {
+                    info!("Issued command to Wiggle valve: {:?}", valve);
+                    self.commands.push(Command::wiggle(valve).into());
+                }
+                Some(PaneAction::SetTiming) => {
+                    info!(
+                        "Issued command to set timing for valve {:?} to {} ms",
+                        valve, timing_ms
+                    );
+                    self.commands
+                        .push(Command::set_atomic_valve_timing(valve, timing_ms).into());
+                    self.set_window_state(valve, ValveWindowState::Open);
+                }
+                Some(PaneAction::SetAperture) => {
+                    info!(
+                        "Issued command to set aperture for valve {:?} to {}%",
+                        valve, aperture
+                    );
+                    self.commands
+                        .push(Command::set_valve_maximum_aperture(valve, aperture / 100.).into());
+                    self.set_window_state(valve, ValveWindowState::Open);
+                }
+                Some(PaneAction::FocusOnTiming) => {
+                    self.set_window_state(valve, ValveWindowState::TimingFocused);
+                }
+                Some(PaneAction::FocusOnAperture) => {
+                    self.set_window_state(valve, ValveWindowState::ApertureFocused);
+                }
+                _ => {}
             }
-            // self.valve_window_states
-            //     .insert(valve, ValveWindowState::Closed);
         }
     }
 
@@ -685,6 +724,11 @@ impl ValveControlPane {
         self.last_refresh = Some(Instant::now());
         self.manual_refresh = false;
     }
+
+    #[inline]
+    fn set_window_state(&mut self, valve: Valve, state: ValveWindowState) {
+        self.valve_window_states.insert(valve, state);
+    }
 }
 
 #[derive(Debug, Clone, Copy)]
diff --git a/src/ui/panes/valve_control/valves.rs b/src/ui/panes/valve_control/valves.rs
index cf13fcb..cf9b15a 100644
--- a/src/ui/panes/valve_control/valves.rs
+++ b/src/ui/panes/valve_control/valves.rs
@@ -128,6 +128,16 @@ pub enum ParameterValue<T, E> {
     Invalid(E), // E is the reason why the parameter is invalid
 }
 
+impl<T, E> ParameterValue<T, E> {
+    pub fn valid_or(self, default: T) -> T {
+        match self {
+            Self::Valid(value) => value,
+            Self::Missing => default,
+            Self::Invalid(_) => default,
+        }
+    }
+}
+
 impl<T: Display, E: Display> Display for ParameterValue<T, E> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
-- 
GitLab


From e9ac853a06108cfaa0e37be2e63af9fdcd1fe7b1 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Tue, 25 Mar 2025 00:41:19 +0100
Subject: [PATCH 20/24] CHECKPOINT

moved code for valve control window to own file
---
 src/ui/panes/valve_control.rs                 | 353 ++----------------
 src/ui/panes/valve_control/ui.rs              |  70 +---
 .../panes/valve_control/ui/shortcut_widget.rs |  66 ++++
 .../valve_control/ui/valve_control_window.rs  | 297 +++++++++++++++
 4 files changed, 398 insertions(+), 388 deletions(-)
 create mode 100644 src/ui/panes/valve_control/ui/shortcut_widget.rs
 create mode 100644 src/ui/panes/valve_control/ui/valve_control_window.rs

diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 793d004..73289d7 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -9,9 +9,8 @@ use std::{
 };
 
 use egui::{
-    Color32, DragValue, FontId, Frame, Grid, Key, KeyboardShortcut, Label, Modal, Modifiers,
-    Response, RichText, Sense, Stroke, TextFormat, Ui, UiBuilder, Vec2, Widget, Window,
-    text::LayoutJob, vec2,
+    Color32, DragValue, FontId, Frame, Grid, Key, Label, Modifiers, Response, RichText, Sense,
+    Stroke, TextFormat, Ui, UiBuilder, Vec2, Widget, Window, text::LayoutJob, vec2,
 };
 use itertools::Itertools;
 use serde::{Deserialize, Serialize};
@@ -20,8 +19,7 @@ use skyward_mavlink::{
     orion::{ACK_TM_DATA, NACK_TM_DATA, WACK_TM_DATA},
 };
 use strum::IntoEnumIterator;
-use tracing::{info, trace, warn};
-use ui::ShortcutCard;
+use tracing::info;
 
 use crate::{
     mavlink::{MavMessage, TimedMessage},
@@ -33,8 +31,9 @@ use crate::{
 
 use super::PaneBehavior;
 
-use commands::{Command, CommandSM};
+use commands::CommandSM;
 use icons::Icon;
+use ui::{ShortcutCard, ValveControlWindow, map_key_to_shortcut};
 use valves::{Valve, ValveStateManager};
 
 const DEFAULT_AUTO_REFRESH_RATE: Duration = Duration::from_secs(1);
@@ -85,7 +84,7 @@ pub struct ValveControlPane {
     #[serde(skip)]
     valve_key_map: HashMap<Valve, Key>,
     #[serde(skip)]
-    valve_window_states: HashMap<Valve, ValveWindowState>,
+    valve_window: Option<ValveControlWindow>,
 }
 
 impl Default for ValveControlPane {
@@ -94,9 +93,6 @@ impl Default for ValveControlPane {
         let valve_key_map = Valve::iter()
             .zip(symbols.into_iter().map(map_symbol_to_key))
             .collect();
-        let valve_window_states = Valve::iter()
-            .map(|v| (v, ValveWindowState::Closed))
-            .collect();
         Self {
             valves_state: ValveStateManager::default(),
             commands: vec![],
@@ -105,7 +101,7 @@ impl Default for ValveControlPane {
             last_refresh: None,
             is_settings_window_open: false,
             valve_key_map,
-            valve_window_states,
+            valve_window: None,
         }
     }
 }
@@ -134,22 +130,14 @@ impl PaneBehavior for ValveControlPane {
         }
 
         // capture actions from keyboard shortcuts
-        let action_to_pass = self.keyboard_actions(shortcut_handler);
+        let action = self.keyboard_actions(shortcut_handler);
 
-        match action_to_pass {
+        match action {
             // Open the valve control window if the action is to open it
             Some(PaneAction::OpenValveControl(valve)) => {
-                self.set_window_state(valve, ValveWindowState::Open);
-            }
-            // Close if the user requests so
-            Some(PaneAction::CloseValveControls) => {
-                warn!("closing all");
-                for valve in Valve::iter() {
-                    self.set_window_state(valve, ValveWindowState::Closed);
-                }
+                self.valve_window.replace(ValveControlWindow::new(valve));
             }
-            // Ignore otherwise
-            _ => {}
+            None => {}
         }
 
         Window::new("Settings")
@@ -160,20 +148,14 @@ impl PaneBehavior for ValveControlPane {
             .open(&mut self.is_settings_window_open)
             .show(ui.ctx(), Self::settings_window_ui(&mut self.auto_refresh));
 
-        if let Some(valve_window_open) = self
-            .valve_window_states
-            .iter()
-            .find(|&(_, state)| !state.is_closed())
-            .map(|(&v, _)| v)
-        {
-            trace!(
-                "Valve control window for valve {} is open",
-                valve_window_open
-            );
-            Modal::new(ui.auto_id_with(format!("valve_control {}", valve_window_open))).show(
-                ui.ctx(),
-                self.valve_control_window_ui(valve_window_open, action_to_pass),
-            );
+        if let Some(valve_window) = &mut self.valve_window {
+            if let Some(command) = valve_window.ui(ui, shortcut_handler) {
+                self.commands.push(command.into());
+            }
+
+            if valve_window.is_closed() {
+                self.valve_window = None;
+            }
         }
 
         pane_response
@@ -259,7 +241,7 @@ impl ValveControlPane {
 
                             if response.clicked() {
                                 info!("Clicked on valve: {:?}", valve);
-                                self.set_window_state(valve, ValveWindowState::Open);
+                                self.valve_window = Some(ValveControlWindow::new(valve));
                             }
                         }
                         ui.end_row();
@@ -399,8 +381,7 @@ impl ValveControlPane {
                     let visuals = ui.style().interact(&response);
 
                     let (fill_color, btn_fill_color, stroke) = if response.clicked()
-                        || shortcut_key_is_down
-                            && self.valve_window_states.values().all(|&v| v.is_closed())
+                        || shortcut_key_is_down && self.valve_window.is_none()
                     {
                         let visuals = ui.visuals().widgets.active;
                         (visuals.bg_fill, visuals.bg_fill, visuals.bg_stroke)
@@ -444,264 +425,18 @@ impl ValveControlPane {
         }
     }
 
-    const WIGGLE_KEY: Key = Key::Minus;
-    const TIMING_KEY: Key = Key::Slash;
-    const APERTURE_KEY: Key = Key::Period;
-
-    fn valve_control_window_ui(
-        &mut self,
-        valve: Valve,
-        mut action: Option<PaneAction>,
-    ) -> impl FnOnce(&mut Ui) {
-        move |ui| {
-            profiling::function_scope!("valve_control_window_ui");
-            let icon_size = Vec2::splat(25.);
-            let text_size = 16.;
-
-            fn btn_ui<R>(
-                window_state: &ValveWindowState,
-                key: Key,
-                add_contents: impl FnOnce(&mut Ui) -> R,
-            ) -> impl FnOnce(&mut Ui) -> Response {
-                move |ui| {
-                    let wiggle_btn = Frame::canvas(ui.style())
-                        .inner_margin(ui.spacing().menu_margin)
-                        .corner_radius(ui.visuals().noninteractive().corner_radius);
-
-                    ui.scope_builder(UiBuilder::new().id_salt(key).sense(Sense::click()), |ui| {
-                        let response = ui.response();
-
-                        let clicked = response.clicked();
-                        let shortcut_down = ui.ctx().input(|input| input.key_down(key));
-
-                        let visuals = ui.style().interact(&response);
-                        let (fill_color, stroke) =
-                            if clicked || shortcut_down && window_state.is_open() {
-                                let visuals = ui.visuals().widgets.active;
-                                (visuals.bg_fill, visuals.bg_stroke)
-                            } else if response.hovered() {
-                                (visuals.bg_fill, visuals.bg_stroke)
-                            } else {
-                                let stroke = Stroke::new(1., Color32::TRANSPARENT);
-                                (visuals.bg_fill.gamma_multiply(0.3), stroke)
-                            };
-
-                        wiggle_btn
-                            .fill(fill_color)
-                            .stroke(stroke)
-                            .stroke(stroke)
-                            .show(ui, |ui| {
-                                ui.set_width(200.);
-                                ui.horizontal(|ui| add_contents(ui))
-                            });
-
-                        if response.clicked() {
-                            info!("Clicked!");
-                        }
-                    })
-                    .response
-                }
-            }
-
-            let window_state = &self.valve_window_states[&valve];
-            let wiggle_btn_response = btn_ui(window_state, Self::WIGGLE_KEY, |ui| {
-                ShortcutCard::new(map_key_to_shortcut(Self::WIGGLE_KEY))
-                    .text_color(ui.visuals().text_color())
-                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
-                    .text_size(20.)
-                    .ui(ui);
-                ui.add(
-                    Icon::Wiggle
-                        .as_image(ui.ctx().theme())
-                        .fit_to_exact_size(icon_size),
-                );
-                ui.add(Label::new(RichText::new("Wiggle").size(text_size)).selectable(false));
-            })(ui);
-
-            let mut aperture = self.valves_state.get_aperture_for(valve).valid_or(0.5) * 100.;
-            let aperture_btn_response = btn_ui(window_state, Self::APERTURE_KEY, |ui| {
-                ShortcutCard::new(map_key_to_shortcut(Self::APERTURE_KEY))
-                    .text_color(ui.visuals().text_color())
-                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
-                    .text_size(20.)
-                    .ui(ui);
-                ui.add(
-                    Icon::Aperture
-                        .as_image(ui.ctx().theme())
-                        .fit_to_exact_size(icon_size),
-                );
-                ui.add(Label::new(RichText::new("Aperture: ").size(text_size)).selectable(false));
-                let drag_value_id = ui.next_auto_id();
-                ui.add(
-                    DragValue::new(&mut aperture)
-                        .speed(0.5)
-                        .range(0.0..=100.0)
-                        .fixed_decimals(0)
-                        .update_while_editing(false)
-                        .suffix("%"),
-                );
-                if matches!(window_state, ValveWindowState::ApertureFocused) {
-                    ui.ctx().memory_mut(|m| {
-                        m.request_focus(drag_value_id);
-                    });
-                }
-            })(ui);
-
-            let mut timing_ms = 0_u32;
-            let timing_btn_response = btn_ui(window_state, Self::TIMING_KEY, |ui| {
-                ShortcutCard::new(map_key_to_shortcut(Self::TIMING_KEY))
-                    .text_color(ui.visuals().text_color())
-                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
-                    .text_size(20.)
-                    .ui(ui);
-                ui.add(
-                    Icon::Timing
-                        .as_image(ui.ctx().theme())
-                        .fit_to_exact_size(icon_size),
-                );
-                ui.add(Label::new(RichText::new("Timing: ").size(text_size)).selectable(false));
-                let drag_value_id = ui.next_auto_id();
-                ui.add(
-                    DragValue::new(&mut timing_ms)
-                        .speed(1)
-                        .range(1..=10000)
-                        .fixed_decimals(0)
-                        .update_while_editing(false)
-                        .suffix(" [ms]"),
-                );
-                if matches!(window_state, ValveWindowState::TimingFocused) {
-                    ui.ctx().memory_mut(|m| {
-                        m.request_focus(drag_value_id);
-                    });
-                }
-            })(ui);
-
-            // consider that action may be different that null if a keyboard shortcut was captured
-            if wiggle_btn_response.clicked() {
-                action = Some(PaneAction::Wiggle);
-            } else if aperture_btn_response.clicked() {
-                action = Some(PaneAction::SetAperture);
-            } else if timing_btn_response.clicked() {
-                action = Some(PaneAction::SetTiming);
-            }
-
-            match action {
-                Some(PaneAction::Wiggle) => {
-                    info!("Issued command to Wiggle valve: {:?}", valve);
-                    self.commands.push(Command::wiggle(valve).into());
-                }
-                Some(PaneAction::SetTiming) => {
-                    info!(
-                        "Issued command to set timing for valve {:?} to {} ms",
-                        valve, timing_ms
-                    );
-                    self.commands
-                        .push(Command::set_atomic_valve_timing(valve, timing_ms).into());
-                    self.set_window_state(valve, ValveWindowState::Open);
-                }
-                Some(PaneAction::SetAperture) => {
-                    info!(
-                        "Issued command to set aperture for valve {:?} to {}%",
-                        valve, aperture
-                    );
-                    self.commands
-                        .push(Command::set_valve_maximum_aperture(valve, aperture / 100.).into());
-                    self.set_window_state(valve, ValveWindowState::Open);
-                }
-                Some(PaneAction::FocusOnTiming) => {
-                    self.set_window_state(valve, ValveWindowState::TimingFocused);
-                }
-                Some(PaneAction::FocusOnAperture) => {
-                    self.set_window_state(valve, ValveWindowState::ApertureFocused);
-                }
-                _ => {}
-            }
-        }
-    }
-
     #[profiling::function]
     fn keyboard_actions(&self, shortcut_handler: &mut ShortcutHandler) -> Option<PaneAction> {
         let mut key_action_pairs = Vec::new();
-        match self
-            .valve_window_states
-            .iter()
-            .find(|&(_, open)| !open.is_closed())
-        {
-            Some((&valve, state)) => {
-                shortcut_handler.activate_mode(ShortcutMode::valve_control());
-                match state {
-                    ValveWindowState::Open => {
-                        // A window is open, so we can map the keys to control the valve
-                        key_action_pairs.push((
-                            Modifiers::NONE,
-                            Self::WIGGLE_KEY,
-                            PaneAction::Wiggle,
-                        ));
-                        key_action_pairs.push((
-                            Modifiers::NONE,
-                            Self::TIMING_KEY,
-                            PaneAction::FocusOnTiming,
-                        ));
-                        key_action_pairs.push((
-                            Modifiers::NONE,
-                            Self::APERTURE_KEY,
-                            PaneAction::FocusOnAperture,
-                        ));
-                        key_action_pairs.push((
-                            Modifiers::NONE,
-                            Key::Escape,
-                            PaneAction::CloseValveControls,
-                        ));
-                    }
-                    ValveWindowState::TimingFocused => {
-                        // The timing field is focused, so we can map the keys to control the timing
-                        key_action_pairs.push((Modifiers::NONE, Key::Enter, PaneAction::SetTiming));
-                        key_action_pairs.push((
-                            Modifiers::NONE,
-                            Key::Escape,
-                            PaneAction::OpenValveControl(valve),
-                        ));
-                    }
-                    ValveWindowState::ApertureFocused => {
-                        // The aperture field is focused, so we can map the keys to control the aperture
-                        key_action_pairs.push((
-                            Modifiers::NONE,
-                            Key::Enter,
-                            PaneAction::SetAperture,
-                        ));
-                        key_action_pairs.push((
-                            Modifiers::NONE,
-                            Key::Escape,
-                            PaneAction::OpenValveControl(valve),
-                        ));
-                    }
-                    ValveWindowState::Closed => unreachable!(),
-                }
-                shortcut_handler
-                    .consume_if_mode_is(ShortcutMode::valve_control(), &key_action_pairs[..])
-            }
-            None => {
-                shortcut_handler.deactivate_mode(ShortcutMode::valve_control());
-                // No window is open, so we can map the keys to open the valve control windows
-                for (&valve, &key) in self.valve_key_map.iter() {
-                    key_action_pairs.push((
-                        Modifiers::NONE,
-                        key,
-                        PaneAction::OpenValveControl(valve),
-                    ));
-                }
-                shortcut_handler
-                    .consume_if_mode_is(ShortcutMode::composition(), &key_action_pairs[..])
-            }
+        shortcut_handler.deactivate_mode(ShortcutMode::valve_control());
+        // No window is open, so we can map the keys to open the valve control windows
+        for (&valve, &key) in self.valve_key_map.iter() {
+            key_action_pairs.push((Modifiers::NONE, key, PaneAction::OpenValveControl(valve)));
         }
+        shortcut_handler.consume_if_mode_is(ShortcutMode::composition(), &key_action_pairs[..])
     }
 }
 
-#[inline]
-fn map_key_to_shortcut(key: Key) -> KeyboardShortcut {
-    KeyboardShortcut::new(Modifiers::NONE, key)
-}
-
 // ┌───────────────────────────┐
 // │       UTILS METHODS       │
 // └───────────────────────────┘
@@ -724,45 +459,9 @@ impl ValveControlPane {
         self.last_refresh = Some(Instant::now());
         self.manual_refresh = false;
     }
-
-    #[inline]
-    fn set_window_state(&mut self, valve: Valve, state: ValveWindowState) {
-        self.valve_window_states.insert(valve, state);
-    }
 }
 
 #[derive(Debug, Clone, Copy)]
 enum PaneAction {
     OpenValveControl(Valve),
-    CloseValveControls,
-    Wiggle,
-    SetTiming,
-    SetAperture,
-    FocusOnTiming,
-    FocusOnAperture,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ValveWindowState {
-    Closed,
-    Open,
-    TimingFocused,
-    ApertureFocused,
-}
-
-impl ValveWindowState {
-    #[inline]
-    fn is_open(&self) -> bool {
-        matches!(self, Self::Open)
-    }
-
-    #[inline]
-    fn is_closed(&self) -> bool {
-        matches!(self, Self::Closed)
-    }
-
-    #[inline]
-    fn is_focused(&self) -> bool {
-        matches!(self, Self::TimingFocused | Self::ApertureFocused)
-    }
 }
diff --git a/src/ui/panes/valve_control/ui.rs b/src/ui/panes/valve_control/ui.rs
index 9bbc056..2c5a1d3 100644
--- a/src/ui/panes/valve_control/ui.rs
+++ b/src/ui/panes/valve_control/ui.rs
@@ -1,66 +1,14 @@
-use egui::{
-    Color32, FontId, Frame, KeyboardShortcut, Label, Margin, ModifierNames, RichText, Stroke,
-    Widget,
-};
+mod shortcut_widget;
+mod valve_control_window;
 
-pub struct ShortcutCard {
-    shortcut: KeyboardShortcut,
-    text_size: f32,
-    text_color: Option<Color32>,
-    fill_color: Option<Color32>,
-}
-
-impl Widget for ShortcutCard {
-    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
-        #[cfg(target_os = "macos")]
-        let is_mac = true;
-        #[cfg(not(target_os = "macos"))]
-        let is_mac = false;
-
-        let shortcut_fmt = self.shortcut.format(&ModifierNames::SYMBOLS, is_mac);
-        let default_style = ui.style().noninteractive();
-        let text_color = self.text_color.unwrap_or(default_style.text_color());
-        let fill_color = self.fill_color.unwrap_or(default_style.bg_fill);
-        let corner_radius = default_style.corner_radius;
-
-        let number = RichText::new(shortcut_fmt)
-            .color(text_color)
-            .font(FontId::monospace(self.text_size));
-
-        Frame::canvas(ui.style())
-            .fill(fill_color)
-            .stroke(Stroke::NONE)
-            .inner_margin(Margin::same(5))
-            .corner_radius(corner_radius)
-            .show(ui, |ui| {
-                Label::new(number).selectable(false).ui(ui);
-            })
-            .response
-    }
-}
-
-impl ShortcutCard {
-    pub fn new(shortcut: KeyboardShortcut) -> Self {
-        Self {
-            shortcut,
-            text_size: 20.,
-            text_color: None,
-            fill_color: None,
-        }
-    }
+use egui::{Key, KeyboardShortcut, Modifiers};
 
-    pub fn text_size(mut self, text_size: f32) -> Self {
-        self.text_size = text_size;
-        self
-    }
+// Re-export the modules for the UI modules
+use super::{commands, icons, valves};
 
-    pub fn text_color(mut self, text_color: Color32) -> Self {
-        self.text_color = Some(text_color);
-        self
-    }
+pub use {shortcut_widget::ShortcutCard, valve_control_window::ValveControlWindow};
 
-    pub fn fill_color(mut self, fill_color: Color32) -> Self {
-        self.fill_color = Some(fill_color);
-        self
-    }
+#[inline]
+pub fn map_key_to_shortcut(key: Key) -> KeyboardShortcut {
+    KeyboardShortcut::new(Modifiers::NONE, key)
 }
diff --git a/src/ui/panes/valve_control/ui/shortcut_widget.rs b/src/ui/panes/valve_control/ui/shortcut_widget.rs
new file mode 100644
index 0000000..9bbc056
--- /dev/null
+++ b/src/ui/panes/valve_control/ui/shortcut_widget.rs
@@ -0,0 +1,66 @@
+use egui::{
+    Color32, FontId, Frame, KeyboardShortcut, Label, Margin, ModifierNames, RichText, Stroke,
+    Widget,
+};
+
+pub struct ShortcutCard {
+    shortcut: KeyboardShortcut,
+    text_size: f32,
+    text_color: Option<Color32>,
+    fill_color: Option<Color32>,
+}
+
+impl Widget for ShortcutCard {
+    fn ui(self, ui: &mut egui::Ui) -> egui::Response {
+        #[cfg(target_os = "macos")]
+        let is_mac = true;
+        #[cfg(not(target_os = "macos"))]
+        let is_mac = false;
+
+        let shortcut_fmt = self.shortcut.format(&ModifierNames::SYMBOLS, is_mac);
+        let default_style = ui.style().noninteractive();
+        let text_color = self.text_color.unwrap_or(default_style.text_color());
+        let fill_color = self.fill_color.unwrap_or(default_style.bg_fill);
+        let corner_radius = default_style.corner_radius;
+
+        let number = RichText::new(shortcut_fmt)
+            .color(text_color)
+            .font(FontId::monospace(self.text_size));
+
+        Frame::canvas(ui.style())
+            .fill(fill_color)
+            .stroke(Stroke::NONE)
+            .inner_margin(Margin::same(5))
+            .corner_radius(corner_radius)
+            .show(ui, |ui| {
+                Label::new(number).selectable(false).ui(ui);
+            })
+            .response
+    }
+}
+
+impl ShortcutCard {
+    pub fn new(shortcut: KeyboardShortcut) -> Self {
+        Self {
+            shortcut,
+            text_size: 20.,
+            text_color: None,
+            fill_color: None,
+        }
+    }
+
+    pub fn text_size(mut self, text_size: f32) -> Self {
+        self.text_size = text_size;
+        self
+    }
+
+    pub fn text_color(mut self, text_color: Color32) -> Self {
+        self.text_color = Some(text_color);
+        self
+    }
+
+    pub fn fill_color(mut self, fill_color: Color32) -> Self {
+        self.fill_color = Some(fill_color);
+        self
+    }
+}
diff --git a/src/ui/panes/valve_control/ui/valve_control_window.rs b/src/ui/panes/valve_control/ui/valve_control_window.rs
new file mode 100644
index 0000000..13a2642
--- /dev/null
+++ b/src/ui/panes/valve_control/ui/valve_control_window.rs
@@ -0,0 +1,297 @@
+use egui::{
+    Color32, DragValue, Frame, Key, Label, Modal, Modifiers, Response, RichText, Sense, Stroke, Ui,
+    UiBuilder, Vec2, Widget,
+};
+use tracing::info;
+
+use crate::ui::shortcuts::{ShortcutHandler, ShortcutMode};
+
+use super::{
+    commands::Command, icons::Icon, map_key_to_shortcut, shortcut_widget::ShortcutCard,
+    valves::Valve,
+};
+
+const WIGGLE_KEY: Key = Key::Minus;
+const TIMING_KEY: Key = Key::Slash;
+const APERTURE_KEY: Key = Key::Period;
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct ValveControlWindow {
+    valve: Valve,
+    state: ValveWindowState,
+    timing_ms: u32,
+    aperture_perc: f32,
+}
+
+impl ValveControlWindow {
+    pub fn new(valve: Valve) -> ValveControlWindow {
+        ValveControlWindow {
+            valve,
+            state: ValveWindowState::Open,
+            timing_ms: 0,
+            aperture_perc: 0.0,
+        }
+    }
+
+    pub fn is_closed(&self) -> bool {
+        matches!(self.state, ValveWindowState::Closed)
+    }
+
+    #[profiling::function]
+    pub fn ui(&mut self, ui: &mut Ui, shortcut_handler: &mut ShortcutHandler) -> Option<Command> {
+        // Show only if the window is open
+        if self.is_closed() {
+            return None;
+        }
+
+        // Capture the keyboard shortcuts
+        let mut action = self.keyboard_actions(shortcut_handler);
+
+        // Draw the window UI
+        Modal::new(ui.auto_id_with("valve_control"))
+            .show(ui.ctx(), self.draw_window_ui(&mut action));
+
+        // Handle the actions
+        self.handle_actions(action)
+    }
+
+    fn draw_window_ui(&mut self, action: &mut Option<WindowAction>) -> impl FnOnce(&mut Ui) {
+        |ui: &mut Ui| {
+            let icon_size = Vec2::splat(25.);
+            let text_size = 16.;
+
+            fn btn_ui<R>(
+                window_state: &ValveWindowState,
+                key: Key,
+                add_contents: impl FnOnce(&mut Ui) -> R,
+            ) -> impl FnOnce(&mut Ui) -> Response {
+                move |ui| {
+                    let wiggle_btn = Frame::canvas(ui.style())
+                        .inner_margin(ui.spacing().menu_margin)
+                        .corner_radius(ui.visuals().noninteractive().corner_radius);
+
+                    ui.scope_builder(UiBuilder::new().id_salt(key).sense(Sense::click()), |ui| {
+                        let response = ui.response();
+
+                        let clicked = response.clicked();
+                        let shortcut_down = ui.ctx().input(|input| input.key_down(key));
+
+                        let visuals = ui.style().interact(&response);
+                        let (fill_color, stroke) =
+                            if clicked || shortcut_down && window_state.is_open() {
+                                let visuals = ui.visuals().widgets.active;
+                                (visuals.bg_fill, visuals.bg_stroke)
+                            } else if response.hovered() {
+                                (visuals.bg_fill, visuals.bg_stroke)
+                            } else {
+                                let stroke = Stroke::new(1., Color32::TRANSPARENT);
+                                (visuals.bg_fill.gamma_multiply(0.3), stroke)
+                            };
+
+                        wiggle_btn
+                            .fill(fill_color)
+                            .stroke(stroke)
+                            .stroke(stroke)
+                            .show(ui, |ui| {
+                                ui.set_width(200.);
+                                ui.horizontal(|ui| add_contents(ui))
+                            });
+
+                        if response.clicked() {
+                            info!("Clicked!");
+                        }
+                    })
+                    .response
+                }
+            }
+
+            let wiggle_btn_response = btn_ui(&self.state, WIGGLE_KEY, |ui| {
+                ShortcutCard::new(map_key_to_shortcut(WIGGLE_KEY))
+                    .text_color(ui.visuals().text_color())
+                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
+                    .text_size(20.)
+                    .ui(ui);
+                ui.add(
+                    Icon::Wiggle
+                        .as_image(ui.ctx().theme())
+                        .fit_to_exact_size(icon_size),
+                );
+                ui.add(Label::new(RichText::new("Wiggle").size(text_size)).selectable(false));
+            })(ui);
+
+            let aperture_btn_response = btn_ui(&self.state, APERTURE_KEY, |ui| {
+                ShortcutCard::new(map_key_to_shortcut(APERTURE_KEY))
+                    .text_color(ui.visuals().text_color())
+                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
+                    .text_size(20.)
+                    .ui(ui);
+                ui.add(
+                    Icon::Aperture
+                        .as_image(ui.ctx().theme())
+                        .fit_to_exact_size(icon_size),
+                );
+                ui.add(Label::new(RichText::new("Aperture: ").size(text_size)).selectable(false));
+                let drag_value_id = ui.next_auto_id();
+                ui.add(
+                    DragValue::new(&mut self.aperture_perc)
+                        .speed(0.5)
+                        .range(0.0..=100.0)
+                        .fixed_decimals(0)
+                        .update_while_editing(false)
+                        .suffix("%"),
+                );
+                if matches!(&self.state, ValveWindowState::ApertureFocused) {
+                    ui.ctx().memory_mut(|m| {
+                        m.request_focus(drag_value_id);
+                    });
+                }
+            })(ui);
+
+            let timing_btn_response = btn_ui(&self.state, TIMING_KEY, |ui| {
+                ShortcutCard::new(map_key_to_shortcut(TIMING_KEY))
+                    .text_color(ui.visuals().text_color())
+                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
+                    .text_size(20.)
+                    .ui(ui);
+                ui.add(
+                    Icon::Timing
+                        .as_image(ui.ctx().theme())
+                        .fit_to_exact_size(icon_size),
+                );
+                ui.add(Label::new(RichText::new("Timing: ").size(text_size)).selectable(false));
+                let drag_value_id = ui.next_auto_id();
+                ui.add(
+                    DragValue::new(&mut self.timing_ms)
+                        .speed(1)
+                        .range(1..=10000)
+                        .fixed_decimals(0)
+                        .update_while_editing(false)
+                        .suffix(" [ms]"),
+                );
+                if matches!(&self.state, ValveWindowState::TimingFocused) {
+                    ui.ctx().memory_mut(|m| {
+                        m.request_focus(drag_value_id);
+                    });
+                }
+            })(ui);
+
+            // consider that action may be different that null if a keyboard shortcut was captured
+            if wiggle_btn_response.clicked() {
+                action.replace(WindowAction::Wiggle);
+            } else if aperture_btn_response.clicked() {
+                action.replace(WindowAction::SetAperture);
+            } else if timing_btn_response.clicked() {
+                action.replace(WindowAction::SetTiming);
+            }
+        }
+    }
+
+    fn handle_actions(&mut self, action: Option<WindowAction>) -> Option<Command> {
+        match action {
+            // If the action close is called, close the window
+            Some(WindowAction::CloseWindow) => {
+                self.state = ValveWindowState::Closed;
+                None
+            }
+            Some(WindowAction::LooseFocus) => {
+                self.state = ValveWindowState::Open;
+                None
+            }
+            Some(WindowAction::Wiggle) => {
+                info!("Issued command to Wiggle valve: {:?}", self.valve);
+                Some(Command::wiggle(self.valve))
+            }
+            Some(WindowAction::SetTiming) => {
+                info!(
+                    "Issued command to set timing for valve {:?} to {} ms",
+                    self.valve, self.timing_ms
+                );
+                self.state = ValveWindowState::Open;
+                Some(Command::set_atomic_valve_timing(self.valve, self.timing_ms))
+            }
+            Some(WindowAction::SetAperture) => {
+                info!(
+                    "Issued command to set aperture for valve {:?} to {}%",
+                    self.valve, self.aperture_perc
+                );
+                self.state = ValveWindowState::Open;
+                Some(Command::set_valve_maximum_aperture(
+                    self.valve,
+                    self.aperture_perc / 100.,
+                ))
+            }
+            Some(WindowAction::FocusOnTiming) => {
+                self.state = ValveWindowState::TimingFocused;
+                None
+            }
+            Some(WindowAction::FocusOnAperture) => {
+                self.state = ValveWindowState::ApertureFocused;
+                None
+            }
+            _ => None,
+        }
+    }
+}
+
+impl ValveControlWindow {
+    #[profiling::function]
+    fn keyboard_actions(&self, shortcut_handler: &mut ShortcutHandler) -> Option<WindowAction> {
+        let mut key_action_pairs = Vec::new();
+
+        shortcut_handler.activate_mode(ShortcutMode::valve_control());
+        match self.state {
+            ValveWindowState::Open => {
+                // A window is open, so we can map the keys to control the valve
+                key_action_pairs.push((Modifiers::NONE, WIGGLE_KEY, WindowAction::Wiggle));
+                key_action_pairs.push((Modifiers::NONE, TIMING_KEY, WindowAction::FocusOnTiming));
+                key_action_pairs.push((
+                    Modifiers::NONE,
+                    APERTURE_KEY,
+                    WindowAction::FocusOnAperture,
+                ));
+                key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::CloseWindow));
+            }
+            ValveWindowState::TimingFocused => {
+                // The timing field is focused, so we can map the keys to control the timing
+                key_action_pairs.push((Modifiers::NONE, Key::Enter, WindowAction::SetTiming));
+                key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::LooseFocus));
+            }
+            ValveWindowState::ApertureFocused => {
+                // The aperture field is focused, so we can map the keys to control the aperture
+                key_action_pairs.push((Modifiers::NONE, Key::Enter, WindowAction::SetAperture));
+                key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::LooseFocus));
+            }
+            ValveWindowState::Closed => {}
+        }
+        shortcut_handler.consume_if_mode_is(ShortcutMode::valve_control(), &key_action_pairs[..])
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ValveWindowState {
+    Closed,
+    Open,
+    TimingFocused,
+    ApertureFocused,
+}
+
+impl ValveWindowState {
+    #[inline]
+    fn is_open(&self) -> bool {
+        matches!(self, Self::Open)
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+enum WindowAction {
+    // window actions
+    CloseWindow,
+    LooseFocus,
+    // commands
+    Wiggle,
+    SetTiming,
+    SetAperture,
+    // UI focus
+    FocusOnTiming,
+    FocusOnAperture,
+}
-- 
GitLab


From 8bc214289b65182ee59131b10bff4789f187701e Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Wed, 2 Apr 2025 20:20:34 +0200
Subject: [PATCH 21/24] Fixed issue with cargo field

---
 Cargo.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Cargo.toml b/Cargo.toml
index 5698828..8c2de1b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ authors = [
     "Niccolò Betto <niccolo.betto@skywarder.eu>",
 ]
 edition = "2024"
-edescription = "Skyward Enhanced Ground Software"
+description = "Skyward Enhanced Ground Software"
 license = "MIT"
 
 [dependencies]
-- 
GitLab


From adb2728deb5a5e97676cd6884678638fc1f4fb8f Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Thu, 10 Apr 2025 19:55:47 +0200
Subject: [PATCH 22/24] WIP

---
 Cargo.lock                                    |  12 +-
 Cargo.toml                                    |   1 +
 src/ui/panes/valve_control.rs                 |  82 +--
 src/ui/panes/valve_control/ui.rs              |   2 +-
 .../panes/valve_control/ui/shortcut_widget.rs |   9 +-
 .../valve_control/ui/valve_control_window.rs  | 544 ++++++++++++++----
 6 files changed, 489 insertions(+), 161 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index f65be72..28368be 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1050,7 +1050,7 @@ checksum = "67756b63b283a65bd0534b0c2a5fb1a12a5768bb6383d422147cc93193d09cfc"
 dependencies = [
  "ahash",
  "egui",
- "itertools",
+ "itertools 0.13.0",
  "log",
  "serde",
 ]
@@ -1827,6 +1827,15 @@ dependencies = [
  "either",
 ]
 
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itoa"
 version = "1.0.15"
@@ -3059,6 +3068,7 @@ dependencies = [
  "egui_tiles",
  "enum_dispatch",
  "glam",
+ "itertools 0.14.0",
  "mavlink-bindgen",
  "mint",
  "profiling",
diff --git a/Cargo.toml b/Cargo.toml
index 8c2de1b..b049c64 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,6 +21,7 @@ egui_plot = "0.31"
 egui_file = "0.22"
 enum_dispatch = "0.3"
 glam = { version = "0.29", features = ["serde", "mint"] }
+itertools = "0.14.0"
 mint = "0.5.9"
 profiling = "1.0"
 ring-channel = "0.12.0"
diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index 73289d7..ed6743b 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -33,7 +33,7 @@ use super::PaneBehavior;
 
 use commands::CommandSM;
 use icons::Icon;
-use ui::{ShortcutCard, ValveControlWindow, map_key_to_shortcut};
+use ui::{ShortcutCard, ValveControlView, map_key_to_shortcut};
 use valves::{Valve, ValveStateManager};
 
 const DEFAULT_AUTO_REFRESH_RATE: Duration = Duration::from_secs(1);
@@ -84,7 +84,7 @@ pub struct ValveControlPane {
     #[serde(skip)]
     valve_key_map: HashMap<Valve, Key>,
     #[serde(skip)]
-    valve_window: Option<ValveControlWindow>,
+    valve_view: Option<ValveControlView>,
 }
 
 impl Default for ValveControlPane {
@@ -101,7 +101,7 @@ impl Default for ValveControlPane {
             last_refresh: None,
             is_settings_window_open: false,
             valve_key_map,
-            valve_window: None,
+            valve_view: None,
         }
     }
 }
@@ -114,48 +114,48 @@ impl PaneBehavior for ValveControlPane {
         // Set this to at least double the maximum icon size used
         Icon::init_cache(ui.ctx(), (100, 100));
 
-        let res = ui
-            .scope_builder(UiBuilder::new().sense(Sense::click_and_drag()), |ui| {
-                self.pane_ui()(ui);
-                ui.allocate_space(ui.available_size());
-            })
-            .response;
-
-        // Show the menu when the user right-clicks the pane
-        res.context_menu(self.menu_ui());
-
-        // Check if the user started dragging the pane
-        if res.drag_started() {
-            pane_response.set_drag_started();
-        }
-
-        // capture actions from keyboard shortcuts
-        let action = self.keyboard_actions(shortcut_handler);
+        if let Some(valve_view) = &mut self.valve_view {
+            if let Some(command) = valve_view.ui(ui, shortcut_handler) {
+                self.commands.push(command.into());
+            }
 
-        match action {
-            // Open the valve control window if the action is to open it
-            Some(PaneAction::OpenValveControl(valve)) => {
-                self.valve_window.replace(ValveControlWindow::new(valve));
+            if valve_view.is_closed() {
+                self.valve_view = None;
+            }
+        } else {
+            let res = ui
+                .scope_builder(UiBuilder::new().sense(Sense::click_and_drag()), |ui| {
+                    self.pane_ui()(ui);
+                    ui.allocate_space(ui.available_size());
+                })
+                .response;
+
+            // Show the menu when the user right-clicks the pane
+            res.context_menu(self.menu_ui());
+
+            // Check if the user started dragging the pane
+            if res.drag_started() {
+                pane_response.set_drag_started();
             }
-            None => {}
-        }
 
-        Window::new("Settings")
-            .id(ui.auto_id_with("settings"))
-            .auto_sized()
-            .collapsible(true)
-            .movable(true)
-            .open(&mut self.is_settings_window_open)
-            .show(ui.ctx(), Self::settings_window_ui(&mut self.auto_refresh));
+            // capture actions from keyboard shortcuts
+            let action = self.keyboard_actions(shortcut_handler);
 
-        if let Some(valve_window) = &mut self.valve_window {
-            if let Some(command) = valve_window.ui(ui, shortcut_handler) {
-                self.commands.push(command.into());
+            match action {
+                // Open the valve control window if the action is to open it
+                Some(PaneAction::OpenValveControl(valve)) => {
+                    self.valve_view.replace(ValveControlView::new(valve));
+                }
+                None => {}
             }
 
-            if valve_window.is_closed() {
-                self.valve_window = None;
-            }
+            Window::new("Settings")
+                .id(ui.auto_id_with("settings"))
+                .auto_sized()
+                .collapsible(true)
+                .movable(true)
+                .open(&mut self.is_settings_window_open)
+                .show(ui.ctx(), Self::settings_window_ui(&mut self.auto_refresh));
         }
 
         pane_response
@@ -241,7 +241,7 @@ impl ValveControlPane {
 
                             if response.clicked() {
                                 info!("Clicked on valve: {:?}", valve);
-                                self.valve_window = Some(ValveControlWindow::new(valve));
+                                self.valve_view = Some(ValveControlView::new(valve));
                             }
                         }
                         ui.end_row();
@@ -381,7 +381,7 @@ impl ValveControlPane {
                     let visuals = ui.style().interact(&response);
 
                     let (fill_color, btn_fill_color, stroke) = if response.clicked()
-                        || shortcut_key_is_down && self.valve_window.is_none()
+                        || shortcut_key_is_down && self.valve_view.is_none()
                     {
                         let visuals = ui.visuals().widgets.active;
                         (visuals.bg_fill, visuals.bg_fill, visuals.bg_stroke)
diff --git a/src/ui/panes/valve_control/ui.rs b/src/ui/panes/valve_control/ui.rs
index 2c5a1d3..6350f61 100644
--- a/src/ui/panes/valve_control/ui.rs
+++ b/src/ui/panes/valve_control/ui.rs
@@ -6,7 +6,7 @@ use egui::{Key, KeyboardShortcut, Modifiers};
 // Re-export the modules for the UI modules
 use super::{commands, icons, valves};
 
-pub use {shortcut_widget::ShortcutCard, valve_control_window::ValveControlWindow};
+pub use {shortcut_widget::ShortcutCard, valve_control_window::ValveControlView};
 
 #[inline]
 pub fn map_key_to_shortcut(key: Key) -> KeyboardShortcut {
diff --git a/src/ui/panes/valve_control/ui/shortcut_widget.rs b/src/ui/panes/valve_control/ui/shortcut_widget.rs
index 9bbc056..212d895 100644
--- a/src/ui/panes/valve_control/ui/shortcut_widget.rs
+++ b/src/ui/panes/valve_control/ui/shortcut_widget.rs
@@ -6,6 +6,7 @@ use egui::{
 pub struct ShortcutCard {
     shortcut: KeyboardShortcut,
     text_size: f32,
+    margin: Margin,
     text_color: Option<Color32>,
     fill_color: Option<Color32>,
 }
@@ -30,7 +31,7 @@ impl Widget for ShortcutCard {
         Frame::canvas(ui.style())
             .fill(fill_color)
             .stroke(Stroke::NONE)
-            .inner_margin(Margin::same(5))
+            .inner_margin(self.margin)
             .corner_radius(corner_radius)
             .show(ui, |ui| {
                 Label::new(number).selectable(false).ui(ui);
@@ -44,6 +45,7 @@ impl ShortcutCard {
         Self {
             shortcut,
             text_size: 20.,
+            margin: Margin::same(5),
             text_color: None,
             fill_color: None,
         }
@@ -63,4 +65,9 @@ impl ShortcutCard {
         self.fill_color = Some(fill_color);
         self
     }
+
+    pub fn margin(mut self, margin: Margin) -> Self {
+        self.margin = margin;
+        self
+    }
 }
diff --git a/src/ui/panes/valve_control/ui/valve_control_window.rs b/src/ui/panes/valve_control/ui/valve_control_window.rs
index 13a2642..5fa463d 100644
--- a/src/ui/panes/valve_control/ui/valve_control_window.rs
+++ b/src/ui/panes/valve_control/ui/valve_control_window.rs
@@ -1,7 +1,8 @@
 use egui::{
-    Color32, DragValue, Frame, Key, Label, Modal, Modifiers, Response, RichText, Sense, Stroke, Ui,
-    UiBuilder, Vec2, Widget,
+    Align, Button, Color32, Direction, DragValue, FontId, Frame, Grid, Key, Label, Layout, Margin,
+    Modifiers, Response, RichText, Sense, Stroke, TextEdit, Ui, UiBuilder, Vec2, Widget,
 };
+use egui_extras::{Size, Strip, StripBuilder};
 use tracing::info;
 
 use crate::ui::shortcuts::{ShortcutHandler, ShortcutMode};
@@ -12,29 +13,33 @@ use super::{
 };
 
 const WIGGLE_KEY: Key = Key::Minus;
-const TIMING_KEY: Key = Key::Slash;
-const APERTURE_KEY: Key = Key::Period;
+/// Key used to focus on the aperture field
+const FOCUS_APERTURE_KEY: Key = Key::Num1;
+/// Key used to focus on the timing field
+const FOCUS_TIMING_KEY: Key = Key::Num2;
+/// Key used to set the parameter and loose focus on the field
+const SET_PAR_KEY: Key = Key::Plus;
 
 #[derive(Debug, Clone, PartialEq)]
-pub struct ValveControlWindow {
+pub struct ValveControlView {
     valve: Valve,
-    state: ValveWindowState,
+    state: ValveViewState,
     timing_ms: u32,
     aperture_perc: f32,
 }
 
-impl ValveControlWindow {
-    pub fn new(valve: Valve) -> ValveControlWindow {
-        ValveControlWindow {
+impl ValveControlView {
+    pub fn new(valve: Valve) -> ValveControlView {
+        ValveControlView {
             valve,
-            state: ValveWindowState::Open,
+            state: ValveViewState::Open,
             timing_ms: 0,
             aperture_perc: 0.0,
         }
     }
 
     pub fn is_closed(&self) -> bool {
-        matches!(self.state, ValveWindowState::Closed)
+        matches!(self.state, ValveViewState::Closed)
     }
 
     #[profiling::function]
@@ -47,27 +52,26 @@ impl ValveControlWindow {
         // Capture the keyboard shortcuts
         let mut action = self.keyboard_actions(shortcut_handler);
 
-        // Draw the window UI
-        Modal::new(ui.auto_id_with("valve_control"))
-            .show(ui.ctx(), self.draw_window_ui(&mut action));
+        // Draw the view inside the pane
+        ui.scope(self.draw_view_ui(&mut action));
 
         // Handle the actions
         self.handle_actions(action)
     }
 
-    fn draw_window_ui(&mut self, action: &mut Option<WindowAction>) -> impl FnOnce(&mut Ui) {
+    fn draw_view_ui(&mut self, action: &mut Option<WindowAction>) -> impl FnOnce(&mut Ui) {
         |ui: &mut Ui| {
-            let icon_size = Vec2::splat(25.);
-            let text_size = 16.;
+            let icon_size = Vec2::splat(20.);
+            let text_size = 14.;
 
             fn btn_ui<R>(
-                window_state: &ValveWindowState,
+                window_state: &ValveViewState,
                 key: Key,
                 add_contents: impl FnOnce(&mut Ui) -> R,
             ) -> impl FnOnce(&mut Ui) -> Response {
                 move |ui| {
-                    let wiggle_btn = Frame::canvas(ui.style())
-                        .inner_margin(ui.spacing().menu_margin)
+                    let btn = Frame::canvas(ui.style())
+                        .inner_margin(Margin::same(4))
                         .corner_radius(ui.visuals().noninteractive().corner_radius);
 
                     ui.scope_builder(UiBuilder::new().id_salt(key).sense(Sense::click()), |ui| {
@@ -88,12 +92,11 @@ impl ValveControlWindow {
                                 (visuals.bg_fill.gamma_multiply(0.3), stroke)
                             };
 
-                        wiggle_btn
-                            .fill(fill_color)
+                        btn.fill(fill_color)
                             .stroke(stroke)
                             .stroke(stroke)
                             .show(ui, |ui| {
-                                ui.set_width(200.);
+                                ui.set_width(ui.available_width());
                                 ui.horizontal(|ui| add_contents(ui))
                             });
 
@@ -105,84 +108,391 @@ impl ValveControlWindow {
                 }
             }
 
-            let wiggle_btn_response = btn_ui(&self.state, WIGGLE_KEY, |ui| {
-                ShortcutCard::new(map_key_to_shortcut(WIGGLE_KEY))
-                    .text_color(ui.visuals().text_color())
-                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
-                    .text_size(20.)
-                    .ui(ui);
-                ui.add(
-                    Icon::Wiggle
-                        .as_image(ui.ctx().theme())
-                        .fit_to_exact_size(icon_size),
-                );
-                ui.add(Label::new(RichText::new("Wiggle").size(text_size)).selectable(false));
-            })(ui);
-
-            let aperture_btn_response = btn_ui(&self.state, APERTURE_KEY, |ui| {
-                ShortcutCard::new(map_key_to_shortcut(APERTURE_KEY))
-                    .text_color(ui.visuals().text_color())
-                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
-                    .text_size(20.)
-                    .ui(ui);
-                ui.add(
-                    Icon::Aperture
-                        .as_image(ui.ctx().theme())
-                        .fit_to_exact_size(icon_size),
-                );
-                ui.add(Label::new(RichText::new("Aperture: ").size(text_size)).selectable(false));
-                let drag_value_id = ui.next_auto_id();
-                ui.add(
-                    DragValue::new(&mut self.aperture_perc)
-                        .speed(0.5)
-                        .range(0.0..=100.0)
-                        .fixed_decimals(0)
-                        .update_while_editing(false)
-                        .suffix("%"),
-                );
-                if matches!(&self.state, ValveWindowState::ApertureFocused) {
-                    ui.ctx().memory_mut(|m| {
-                        m.request_focus(drag_value_id);
-                    });
+            let valid_fill = ui
+                .visuals()
+                .widgets
+                .inactive
+                .bg_fill
+                .lerp_to_gamma(Color32::GREEN, 0.3);
+            let invalid_fill = ui
+                .visuals()
+                .widgets
+                .inactive
+                .bg_fill
+                .lerp_to_gamma(Color32::RED, 0.3);
+
+            fn shortcut_ui(ui: &Ui, key: &Key) -> ShortcutCard {
+                let vis = ui.visuals();
+                ShortcutCard::new(map_key_to_shortcut(*key))
+                    .text_color(vis.strong_text_color())
+                    .fill_color(vis.gray_out(vis.widgets.inactive.bg_fill))
+                    .margin(Margin::symmetric(5, 2))
+                    .text_size(12.)
+            }
+
+            fn add_parameter_btn(ui: &mut Ui, key: Key) -> Response {
+                ui.scope_builder(UiBuilder::new().id_salt(key).sense(Sense::click()), |ui| {
+                    Frame::canvas(ui.style())
+                        .inner_margin(Margin::symmetric(4, 2))
+                        .outer_margin(0)
+                        .corner_radius(ui.visuals().noninteractive().corner_radius)
+                        .fill(ui.visuals().widgets.inactive.bg_fill)
+                        .stroke(Stroke::new(1., Color32::TRANSPARENT))
+                        .show(ui, |ui| {
+                            ui.set_height(ui.available_height());
+                            ui.horizontal_centered(|ui| {
+                                ui.set_height(21.);
+                                ui.add_space(1.);
+                                Label::new(
+                                    RichText::new("SET")
+                                        .size(16.)
+                                        .color(ui.visuals().widgets.inactive.text_color()),
+                                )
+                                .selectable(false)
+                                .ui(ui);
+                                shortcut_ui(ui, &key).ui(ui);
+                            });
+                        });
+                })
+                .response
+            }
+
+            // set aperture and timing buttons
+            let aperture_btn: Box<dyn FnOnce(&mut Ui) -> Response> = match self.state {
+                ValveViewState::Open => Box::new(|ui| add_parameter_btn(ui, FOCUS_APERTURE_KEY)),
+                ValveViewState::ApertureFocused => {
+                    Box::new(|ui| add_parameter_btn(ui, SET_PAR_KEY))
                 }
-            })(ui);
-
-            let timing_btn_response = btn_ui(&self.state, TIMING_KEY, |ui| {
-                ShortcutCard::new(map_key_to_shortcut(TIMING_KEY))
-                    .text_color(ui.visuals().text_color())
-                    .fill_color(ui.visuals().widgets.inactive.bg_fill)
-                    .text_size(20.)
-                    .ui(ui);
-                ui.add(
-                    Icon::Timing
-                        .as_image(ui.ctx().theme())
-                        .fit_to_exact_size(icon_size),
-                );
-                ui.add(Label::new(RichText::new("Timing: ").size(text_size)).selectable(false));
-                let drag_value_id = ui.next_auto_id();
-                ui.add(
-                    DragValue::new(&mut self.timing_ms)
-                        .speed(1)
-                        .range(1..=10000)
-                        .fixed_decimals(0)
-                        .update_while_editing(false)
-                        .suffix(" [ms]"),
+                ValveViewState::TimingFocused | ValveViewState::Closed => {
+                    Box::new(|ui| ui.response())
+                }
+            };
+
+            // set timing button
+            let timing_btn: Box<dyn FnOnce(&mut Ui) -> Response> = match self.state {
+                ValveViewState::Open => Box::new(|ui| add_parameter_btn(ui, FOCUS_TIMING_KEY)),
+                ValveViewState::TimingFocused => Box::new(|ui| add_parameter_btn(ui, SET_PAR_KEY)),
+                ValveViewState::ApertureFocused | ValveViewState::Closed => {
+                    Box::new(|ui| ui.response())
+                }
+            };
+
+            // wiggle button with shortcut
+            let wiggle_btn = |ui: &mut Ui| {
+                ui.scope_builder(
+                    UiBuilder::new().id_salt(WIGGLE_KEY).sense(Sense::click()),
+                    |ui| {
+                        Frame::canvas(ui.style())
+                            .inner_margin(Margin::symmetric(4, 2))
+                            .outer_margin(0)
+                            .corner_radius(ui.visuals().noninteractive().corner_radius)
+                            .fill(ui.visuals().widgets.inactive.bg_fill)
+                            .stroke(Stroke::new(1., Color32::TRANSPARENT))
+                            .show(ui, |ui| {
+                                ui.set_height(ui.available_height());
+                                ui.horizontal_centered(|ui| {
+                                    ui.set_height(21.);
+                                    ui.add_space(1.);
+                                    Label::new(
+                                        RichText::new("WIGGLE")
+                                            .size(16.)
+                                            .color(ui.visuals().widgets.inactive.text_color()),
+                                    )
+                                    .selectable(false)
+                                    .ui(ui);
+                                    ui.add(
+                                        Icon::Wiggle
+                                            .as_image(ui.ctx().theme())
+                                            .fit_to_exact_size(Vec2::splat(22.)),
+                                    );
+                                    shortcut_ui(ui, &WIGGLE_KEY).ui(ui);
+                                });
+                            });
+                    },
                 );
-                if matches!(&self.state, ValveWindowState::TimingFocused) {
-                    ui.ctx().memory_mut(|m| {
-                        m.request_focus(drag_value_id);
+            };
+
+            ui.with_layout(Layout::centered_and_justified(Direction::TopDown), |ui| {
+                ui.set_max_width(300.);
+                ui.set_min_height(50.);
+                StripBuilder::new(ui)
+                    .size(Size::exact(5.))
+                    .sizes(Size::initial(5.), 3)
+                    .vertical(|mut strip| {
+                        strip.empty();
+                        // strip.cell(|ui| {
+                        //     // ui.add_sized(
+                        //     //     Vec2::new(ui.available_width(), 0.0),
+                        //     //     Button::new("Wiggle"),
+                        //     // );
+                        //     let wiggle_btn_response = btn_ui(&self.state, WIGGLE_KEY, |ui| {
+                        //         shortcut_ui(ui, &WIGGLE_KEY).ui(ui);
+                        //         ui.add(
+                        //             Icon::Wiggle
+                        //                 .as_image(ui.ctx().theme())
+                        //                 .fit_to_exact_size(icon_size),
+                        //         );
+                        //         ui.add(
+                        //             Label::new(RichText::new("WIGGLE").size(text_size))
+                        //                 .selectable(false),
+                        //         );
+                        //     })(ui);
+                        // });
+                        strip.strip(|builder| {
+                            builder
+                                // .size(Size::exact(230.))
+                                .size(Size::initial(10.))
+                                .size(Size::exact(25.))
+                                .horizontal(|mut strip| {
+                                    strip.strip(|builder| {
+                                        builder
+                                            .size(Size::remainder())
+                                            .size(Size::initial(5.))
+                                            .size(Size::remainder())
+                                            .vertical(|mut strip| {
+                                                strip.empty();
+                                                strip.cell(|ui| {
+                                                    ui.with_layout(
+                                                        Layout::right_to_left(Align::Min),
+                                                        |ui| {
+                                                            Label::new(
+                                                                RichText::new(
+                                                                    self.valve
+                                                                        .to_string()
+                                                                        .to_uppercase(),
+                                                                )
+                                                                .color(
+                                                                    ui.visuals()
+                                                                        .strong_text_color(),
+                                                                )
+                                                                .size(16.),
+                                                            )
+                                                            .ui(ui);
+                                                            Label::new(
+                                                                RichText::new("VALVE: ").size(16.),
+                                                            )
+                                                            .selectable(false)
+                                                            .ui(ui);
+                                                        },
+                                                    );
+                                                });
+                                                strip.empty();
+                                            });
+                                    });
+                                    strip.cell(wiggle_btn);
+                                });
+                        });
+                        strip.strip(|builder| {
+                            builder
+                                .sizes(Size::initial(85.), 4)
+                                .horizontal(|mut strip| {
+                                    strip.strip(|builder| {
+                                        builder
+                                            .size(Size::remainder())
+                                            .size(Size::initial(5.))
+                                            .size(Size::remainder())
+                                            .vertical(|mut strip| {
+                                                strip.empty();
+                                                strip.cell(|ui| {
+                                                    ui.with_layout(
+                                                        Layout::right_to_left(Align::Min),
+                                                        |ui| {
+                                                            Label::new(
+                                                                RichText::new("APERTURE:")
+                                                                    .size(16.),
+                                                            )
+                                                            .selectable(false)
+                                                            .ui(ui);
+                                                        },
+                                                    );
+                                                });
+                                                strip.empty();
+                                            });
+                                    });
+                                    strip.cell(|ui| {
+                                        Frame::canvas(ui.style())
+                                            .outer_margin(0)
+                                            .inner_margin(Margin::symmetric(0, 3))
+                                            .corner_radius(
+                                                ui.visuals().noninteractive().corner_radius,
+                                            )
+                                            .fill(invalid_fill)
+                                            .stroke(Stroke::new(1., Color32::TRANSPARENT))
+                                            .show(ui, |ui| {
+                                                Label::new(
+                                                    RichText::new("0.813").size(14.).strong(),
+                                                )
+                                                .ui(ui);
+                                            });
+                                    });
+                                    strip.cell(|ui| {
+                                        Frame::canvas(ui.style())
+                                            .inner_margin(Margin::symmetric(0, 3))
+                                            .outer_margin(0)
+                                            .corner_radius(
+                                                ui.visuals().noninteractive().corner_radius,
+                                            )
+                                            .fill(ui.visuals().widgets.inactive.bg_fill)
+                                            .stroke(Stroke::new(1., Color32::TRANSPARENT))
+                                            .show(ui, |ui| {
+                                                let res = ui.add_sized(
+                                                    Vec2::new(ui.available_width(), 0.0),
+                                                    DragValue::new(&mut self.aperture_perc)
+                                                        .speed(0.5)
+                                                        .range(0.0..=100.0)
+                                                        .fixed_decimals(0)
+                                                        .update_while_editing(false)
+                                                        .suffix("%"),
+                                                );
+                                                if res.gained_focus() {
+                                                    self.state = ValveViewState::ApertureFocused;
+                                                }
+                                            });
+                                    });
+                                    strip.cell(|ui| {
+                                        aperture_btn(ui);
+                                    });
+                                });
+                        });
+                        strip.strip(|builder| {
+                            builder
+                                .sizes(Size::initial(85.), 4)
+                                .horizontal(|mut strip| {
+                                    strip.strip(|builder| {
+                                        builder
+                                            .size(Size::remainder())
+                                            .size(Size::initial(10.))
+                                            .size(Size::remainder())
+                                            .vertical(|mut strip| {
+                                                strip.empty();
+                                                strip.cell(|ui| {
+                                                    ui.with_layout(
+                                                        Layout::right_to_left(Align::Min),
+                                                        |ui| {
+                                                            Label::new(
+                                                                RichText::new("TIMING:").size(16.),
+                                                            )
+                                                            .selectable(false)
+                                                            .ui(ui);
+                                                        },
+                                                    );
+                                                });
+                                                strip.empty();
+                                            });
+                                    });
+                                    strip.cell(|ui| {
+                                        Frame::canvas(ui.style())
+                                            .inner_margin(Margin::same(4))
+                                            .corner_radius(
+                                                ui.visuals().noninteractive().corner_radius,
+                                            )
+                                            .fill(valid_fill)
+                                            .stroke(Stroke::new(1., Color32::TRANSPARENT))
+                                            .show(ui, |ui| {
+                                                Label::new(
+                                                    RichText::new("650ms").size(14.).strong(),
+                                                )
+                                                .ui(ui);
+                                            });
+                                    });
+                                    strip.cell(|ui| {
+                                        Frame::canvas(ui.style())
+                                            .inner_margin(Margin::same(4))
+                                            .corner_radius(
+                                                ui.visuals().noninteractive().corner_radius,
+                                            )
+                                            .fill(ui.visuals().widgets.inactive.bg_fill)
+                                            .stroke(Stroke::new(1., Color32::TRANSPARENT))
+                                            .show(ui, |ui| {
+                                                ui.add_sized(
+                                                    Vec2::new(ui.available_width(), 0.0),
+                                                    DragValue::new(&mut self.timing_ms)
+                                                        .speed(1)
+                                                        .range(1..=10000)
+                                                        .fixed_decimals(0)
+                                                        .update_while_editing(false)
+                                                        .suffix(" [ms]"),
+                                                );
+                                            });
+                                    });
+                                    strip.cell(|ui| {
+                                        timing_btn(ui);
+                                    });
+                                });
+                        });
                     });
-                }
-            })(ui);
-
-            // consider that action may be different that null if a keyboard shortcut was captured
-            if wiggle_btn_response.clicked() {
-                action.replace(WindowAction::Wiggle);
-            } else if aperture_btn_response.clicked() {
-                action.replace(WindowAction::SetAperture);
-            } else if timing_btn_response.clicked() {
-                action.replace(WindowAction::SetTiming);
-            }
+            });
+
+            // ui.horizontal(|ui| {
+            //     let wiggle_btn_response = btn_ui(&self.state, WIGGLE_KEY, |ui| {
+            //         shortcut_ui(ui, &WIGGLE_KEY).ui(ui);
+            //         ui.add(
+            //             Icon::Wiggle
+            //                 .as_image(ui.ctx().theme())
+            //                 .fit_to_exact_size(icon_size),
+            //         );
+            //         ui.add(Label::new(RichText::new("Wiggle").size(text_size)).selectable(false));
+            //     })(ui);
+
+            //     let aperture_btn_response = btn_ui(&self.state, APERTURE_KEY, |ui| {
+            //         shortcut_ui(ui, &APERTURE_KEY).ui(ui);
+            //         ui.add(
+            //             Icon::Aperture
+            //                 .as_image(ui.ctx().theme())
+            //                 .fit_to_exact_size(icon_size),
+            //         );
+            //         ui.add(
+            //             Label::new(RichText::new("Aperture: ").size(text_size)).selectable(false),
+            //         );
+            //         let drag_value_id = ui.next_auto_id();
+            //         ui.add(
+            //             DragValue::new(&mut self.aperture_perc)
+            //                 .speed(0.5)
+            //                 .range(0.0..=100.0)
+            //                 .fixed_decimals(0)
+            //                 .update_while_editing(false)
+            //                 .suffix("%"),
+            //         );
+            //         if matches!(&self.state, ValveViewState::ApertureFocused) {
+            //             ui.ctx().memory_mut(|m| {
+            //                 m.request_focus(drag_value_id);
+            //             });
+            //         }
+            //     })(ui);
+
+            //     let timing_btn_response = btn_ui(&self.state, TIMING_KEY, |ui| {
+            //         shortcut_ui(ui, &TIMING_KEY).ui(ui);
+            //         ui.add(
+            //             Icon::Timing
+            //                 .as_image(ui.ctx().theme())
+            //                 .fit_to_exact_size(icon_size),
+            //         );
+            //         ui.add(Label::new(RichText::new("Timing: ").size(text_size)).selectable(false));
+            //         let drag_value_id = ui.next_auto_id();
+            //         ui.add(
+            //             DragValue::new(&mut self.timing_ms)
+            //                 .speed(1)
+            //                 .range(1..=10000)
+            //                 .fixed_decimals(0)
+            //                 .update_while_editing(false)
+            //                 .suffix(" [ms]"),
+            //         );
+            //         if matches!(&self.state, ValveViewState::TimingFocused) {
+            //             ui.ctx().memory_mut(|m| {
+            //                 m.request_focus(drag_value_id);
+            //             });
+            //         }
+            //     })(ui);
+
+            //     // consider that action may be different that null if a keyboard shortcut was captured
+            //     if wiggle_btn_response.clicked() {
+            //         action.replace(WindowAction::Wiggle);
+            //     } else if aperture_btn_response.clicked() {
+            //         action.replace(WindowAction::SetAperture);
+            //     } else if timing_btn_response.clicked() {
+            //         action.replace(WindowAction::SetTiming);
+            //     }
+            // });
         }
     }
 
@@ -190,11 +500,11 @@ impl ValveControlWindow {
         match action {
             // If the action close is called, close the window
             Some(WindowAction::CloseWindow) => {
-                self.state = ValveWindowState::Closed;
+                self.state = ValveViewState::Closed;
                 None
             }
             Some(WindowAction::LooseFocus) => {
-                self.state = ValveWindowState::Open;
+                self.state = ValveViewState::Open;
                 None
             }
             Some(WindowAction::Wiggle) => {
@@ -206,7 +516,7 @@ impl ValveControlWindow {
                     "Issued command to set timing for valve {:?} to {} ms",
                     self.valve, self.timing_ms
                 );
-                self.state = ValveWindowState::Open;
+                self.state = ValveViewState::Open;
                 Some(Command::set_atomic_valve_timing(self.valve, self.timing_ms))
             }
             Some(WindowAction::SetAperture) => {
@@ -214,18 +524,18 @@ impl ValveControlWindow {
                     "Issued command to set aperture for valve {:?} to {}%",
                     self.valve, self.aperture_perc
                 );
-                self.state = ValveWindowState::Open;
+                self.state = ValveViewState::Open;
                 Some(Command::set_valve_maximum_aperture(
                     self.valve,
                     self.aperture_perc / 100.,
                 ))
             }
             Some(WindowAction::FocusOnTiming) => {
-                self.state = ValveWindowState::TimingFocused;
+                self.state = ValveViewState::TimingFocused;
                 None
             }
             Some(WindowAction::FocusOnAperture) => {
-                self.state = ValveWindowState::ApertureFocused;
+                self.state = ValveViewState::ApertureFocused;
                 None
             }
             _ => None,
@@ -233,49 +543,49 @@ impl ValveControlWindow {
     }
 }
 
-impl ValveControlWindow {
+impl ValveControlView {
     #[profiling::function]
     fn keyboard_actions(&self, shortcut_handler: &mut ShortcutHandler) -> Option<WindowAction> {
         let mut key_action_pairs = Vec::new();
 
         shortcut_handler.activate_mode(ShortcutMode::valve_control());
         match self.state {
-            ValveWindowState::Open => {
+            ValveViewState::Open => {
                 // A window is open, so we can map the keys to control the valve
                 key_action_pairs.push((Modifiers::NONE, WIGGLE_KEY, WindowAction::Wiggle));
-                key_action_pairs.push((Modifiers::NONE, TIMING_KEY, WindowAction::FocusOnTiming));
-                key_action_pairs.push((
-                    Modifiers::NONE,
-                    APERTURE_KEY,
-                    WindowAction::FocusOnAperture,
-                ));
+                // key_action_pairs.push((Modifiers::NONE, TIMING_KEY, WindowAction::FocusOnTiming));
+                // key_action_pairs.push((
+                //     Modifiers::NONE,
+                //     APERTURE_KEY,
+                //     WindowAction::FocusOnAperture,
+                // ));
                 key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::CloseWindow));
             }
-            ValveWindowState::TimingFocused => {
+            ValveViewState::TimingFocused => {
                 // The timing field is focused, so we can map the keys to control the timing
                 key_action_pairs.push((Modifiers::NONE, Key::Enter, WindowAction::SetTiming));
                 key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::LooseFocus));
             }
-            ValveWindowState::ApertureFocused => {
+            ValveViewState::ApertureFocused => {
                 // The aperture field is focused, so we can map the keys to control the aperture
                 key_action_pairs.push((Modifiers::NONE, Key::Enter, WindowAction::SetAperture));
                 key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::LooseFocus));
             }
-            ValveWindowState::Closed => {}
+            ValveViewState::Closed => {}
         }
         shortcut_handler.consume_if_mode_is(ShortcutMode::valve_control(), &key_action_pairs[..])
     }
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ValveWindowState {
+enum ValveViewState {
     Closed,
     Open,
     TimingFocused,
     ApertureFocused,
 }
 
-impl ValveWindowState {
+impl ValveViewState {
     #[inline]
     fn is_open(&self) -> bool {
         matches!(self, Self::Open)
-- 
GitLab


From 8f1c41825268608a7f96b17355a7abbc368868dd Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Fri, 11 Apr 2025 17:15:24 +0200
Subject: [PATCH 23/24] CHECKPOINT

---
 src/ui/panes/valve_control.rs                 |  10 +-
 .../valve_control/ui/valve_control_window.rs  | 488 +++++++++---------
 2 files changed, 250 insertions(+), 248 deletions(-)

diff --git a/src/ui/panes/valve_control.rs b/src/ui/panes/valve_control.rs
index ed6743b..22a86e6 100644
--- a/src/ui/panes/valve_control.rs
+++ b/src/ui/panes/valve_control.rs
@@ -144,7 +144,10 @@ impl PaneBehavior for ValveControlPane {
             match action {
                 // Open the valve control window if the action is to open it
                 Some(PaneAction::OpenValveControl(valve)) => {
-                    self.valve_view.replace(ValveControlView::new(valve));
+                    self.valve_view.replace(ValveControlView::new(
+                        valve,
+                        ui.id().with(valve.to_string()),
+                    ));
                 }
                 None => {}
             }
@@ -241,7 +244,10 @@ impl ValveControlPane {
 
                             if response.clicked() {
                                 info!("Clicked on valve: {:?}", valve);
-                                self.valve_view = Some(ValveControlView::new(valve));
+                                self.valve_view = Some(ValveControlView::new(
+                                    valve,
+                                    ui.id().with(valve.to_string()),
+                                ));
                             }
                         }
                         ui.end_row();
diff --git a/src/ui/panes/valve_control/ui/valve_control_window.rs b/src/ui/panes/valve_control/ui/valve_control_window.rs
index 5fa463d..470b6a2 100644
--- a/src/ui/panes/valve_control/ui/valve_control_window.rs
+++ b/src/ui/panes/valve_control/ui/valve_control_window.rs
@@ -1,8 +1,8 @@
 use egui::{
-    Align, Button, Color32, Direction, DragValue, FontId, Frame, Grid, Key, Label, Layout, Margin,
-    Modifiers, Response, RichText, Sense, Stroke, TextEdit, Ui, UiBuilder, Vec2, Widget,
+    Align, Color32, Context, Direction, DragValue, Frame, Id, Key, Label, Layout, Margin,
+    Modifiers, Response, RichText, Sense, Stroke, Ui, UiBuilder, Vec2, Widget,
 };
-use egui_extras::{Size, Strip, StripBuilder};
+use egui_extras::{Size, StripBuilder};
 use tracing::info;
 
 use crate::ui::shortcuts::{ShortcutHandler, ShortcutMode};
@@ -26,15 +26,17 @@ pub struct ValveControlView {
     state: ValveViewState,
     timing_ms: u32,
     aperture_perc: f32,
+    id: Id,
 }
 
 impl ValveControlView {
-    pub fn new(valve: Valve) -> ValveControlView {
+    pub fn new(valve: Valve, id: Id) -> ValveControlView {
         ValveControlView {
             valve,
             state: ValveViewState::Open,
             timing_ms: 0,
             aperture_perc: 0.0,
+            id,
         }
     }
 
@@ -56,57 +58,16 @@ impl ValveControlView {
         ui.scope(self.draw_view_ui(&mut action));
 
         // Handle the actions
-        self.handle_actions(action)
+        self.handle_actions(action, ui.ctx())
     }
 
+    // DISCLAIMER: the code for the UI is really ugly, still learning how to use
+    // egui and in a hurry due to deadlines. If you know how to do it better
+    // feel free to help us
     fn draw_view_ui(&mut self, action: &mut Option<WindowAction>) -> impl FnOnce(&mut Ui) {
         |ui: &mut Ui| {
-            let icon_size = Vec2::splat(20.);
-            let text_size = 14.;
-
-            fn btn_ui<R>(
-                window_state: &ValveViewState,
-                key: Key,
-                add_contents: impl FnOnce(&mut Ui) -> R,
-            ) -> impl FnOnce(&mut Ui) -> Response {
-                move |ui| {
-                    let btn = Frame::canvas(ui.style())
-                        .inner_margin(Margin::same(4))
-                        .corner_radius(ui.visuals().noninteractive().corner_radius);
-
-                    ui.scope_builder(UiBuilder::new().id_salt(key).sense(Sense::click()), |ui| {
-                        let response = ui.response();
-
-                        let clicked = response.clicked();
-                        let shortcut_down = ui.ctx().input(|input| input.key_down(key));
-
-                        let visuals = ui.style().interact(&response);
-                        let (fill_color, stroke) =
-                            if clicked || shortcut_down && window_state.is_open() {
-                                let visuals = ui.visuals().widgets.active;
-                                (visuals.bg_fill, visuals.bg_stroke)
-                            } else if response.hovered() {
-                                (visuals.bg_fill, visuals.bg_stroke)
-                            } else {
-                                let stroke = Stroke::new(1., Color32::TRANSPARENT);
-                                (visuals.bg_fill.gamma_multiply(0.3), stroke)
-                            };
-
-                        btn.fill(fill_color)
-                            .stroke(stroke)
-                            .stroke(stroke)
-                            .show(ui, |ui| {
-                                ui.set_width(ui.available_width());
-                                ui.horizontal(|ui| add_contents(ui))
-                            });
-
-                        if response.clicked() {
-                            info!("Clicked!");
-                        }
-                    })
-                    .response
-                }
-            }
+            let aperture_field_focus = self.id.with("aperture_field_focus");
+            let timing_field_focus = self.id.with("timing_field_focus");
 
             let valid_fill = ui
                 .visuals()
@@ -121,22 +82,26 @@ impl ValveControlView {
                 .bg_fill
                 .lerp_to_gamma(Color32::RED, 0.3);
 
-            fn shortcut_ui(ui: &Ui, key: &Key) -> ShortcutCard {
+            fn shortcut_ui(ui: &Ui, key: &Key, upper_response: &Response) -> ShortcutCard {
                 let vis = ui.visuals();
+                let uvis = ui.style().interact(upper_response);
                 ShortcutCard::new(map_key_to_shortcut(*key))
                     .text_color(vis.strong_text_color())
-                    .fill_color(vis.gray_out(vis.widgets.inactive.bg_fill))
+                    .fill_color(vis.gray_out(uvis.bg_fill))
                     .margin(Margin::symmetric(5, 2))
                     .text_size(12.)
             }
 
             fn add_parameter_btn(ui: &mut Ui, key: Key) -> Response {
                 ui.scope_builder(UiBuilder::new().id_salt(key).sense(Sense::click()), |ui| {
+                    let visuals = *ui.style().interact(&ui.response());
+                    let shortcut_card = shortcut_ui(ui, &key, &ui.response());
+
                     Frame::canvas(ui.style())
                         .inner_margin(Margin::symmetric(4, 2))
                         .outer_margin(0)
                         .corner_radius(ui.visuals().noninteractive().corner_radius)
-                        .fill(ui.visuals().widgets.inactive.bg_fill)
+                        .fill(visuals.bg_fill)
                         .stroke(Stroke::new(1., Color32::TRANSPARENT))
                         .show(ui, |ui| {
                             ui.set_height(ui.available_height());
@@ -144,13 +109,11 @@ impl ValveControlView {
                                 ui.set_height(21.);
                                 ui.add_space(1.);
                                 Label::new(
-                                    RichText::new("SET")
-                                        .size(16.)
-                                        .color(ui.visuals().widgets.inactive.text_color()),
+                                    RichText::new("SET").size(16.).color(visuals.text_color()),
                                 )
                                 .selectable(false)
                                 .ui(ui);
-                                shortcut_ui(ui, &key).ui(ui);
+                                shortcut_card.ui(ui);
                             });
                         });
                 })
@@ -158,91 +121,117 @@ impl ValveControlView {
             }
 
             // set aperture and timing buttons
-            let aperture_btn: Box<dyn FnOnce(&mut Ui) -> Response> = match self.state {
-                ValveViewState::Open => Box::new(|ui| add_parameter_btn(ui, FOCUS_APERTURE_KEY)),
-                ValveViewState::ApertureFocused => {
-                    Box::new(|ui| add_parameter_btn(ui, SET_PAR_KEY))
-                }
-                ValveViewState::TimingFocused | ValveViewState::Closed => {
-                    Box::new(|ui| ui.response())
+            fn show_aperture_btn(
+                state: &ValveViewState,
+                action: &mut Option<WindowAction>,
+                ui: &mut Ui,
+            ) -> Response {
+                let res = match state {
+                    ValveViewState::Open => Some(add_parameter_btn(ui, FOCUS_APERTURE_KEY)),
+                    ValveViewState::ApertureFocused => Some(add_parameter_btn(ui, SET_PAR_KEY)),
+                    ValveViewState::TimingFocused | ValveViewState::Closed => None,
+                };
+                if let Some(res) = &res {
+                    if res.clicked() {
+                        // set the focus on the aperture field
+                        action.replace(WindowAction::SetAperture);
+                    }
                 }
-            };
+                res.unwrap_or_else(|| ui.response())
+            }
 
             // set timing button
-            let timing_btn: Box<dyn FnOnce(&mut Ui) -> Response> = match self.state {
-                ValveViewState::Open => Box::new(|ui| add_parameter_btn(ui, FOCUS_TIMING_KEY)),
-                ValveViewState::TimingFocused => Box::new(|ui| add_parameter_btn(ui, SET_PAR_KEY)),
-                ValveViewState::ApertureFocused | ValveViewState::Closed => {
-                    Box::new(|ui| ui.response())
+            fn show_timing_btn(
+                state: &ValveViewState,
+                action: &mut Option<WindowAction>,
+                ui: &mut Ui,
+            ) -> Response {
+                let res = match state {
+                    ValveViewState::Open => Some(add_parameter_btn(ui, FOCUS_TIMING_KEY)),
+                    ValveViewState::TimingFocused => Some(add_parameter_btn(ui, SET_PAR_KEY)),
+                    ValveViewState::ApertureFocused | ValveViewState::Closed => None,
+                };
+                if let Some(res) = &res {
+                    if res.clicked() {
+                        // set the focus on the aperture field
+                        action.replace(WindowAction::SetTiming);
+                    }
                 }
-            };
+                res.unwrap_or_else(|| ui.response())
+            }
 
             // wiggle button with shortcut
-            let wiggle_btn = |ui: &mut Ui| {
-                ui.scope_builder(
-                    UiBuilder::new().id_salt(WIGGLE_KEY).sense(Sense::click()),
-                    |ui| {
-                        Frame::canvas(ui.style())
-                            .inner_margin(Margin::symmetric(4, 2))
-                            .outer_margin(0)
-                            .corner_radius(ui.visuals().noninteractive().corner_radius)
-                            .fill(ui.visuals().widgets.inactive.bg_fill)
-                            .stroke(Stroke::new(1., Color32::TRANSPARENT))
-                            .show(ui, |ui| {
-                                ui.set_height(ui.available_height());
-                                ui.horizontal_centered(|ui| {
-                                    ui.set_height(21.);
-                                    ui.add_space(1.);
-                                    Label::new(
-                                        RichText::new("WIGGLE")
-                                            .size(16.)
-                                            .color(ui.visuals().widgets.inactive.text_color()),
-                                    )
-                                    .selectable(false)
-                                    .ui(ui);
-                                    ui.add(
-                                        Icon::Wiggle
-                                            .as_image(ui.ctx().theme())
-                                            .fit_to_exact_size(Vec2::splat(22.)),
-                                    );
-                                    shortcut_ui(ui, &WIGGLE_KEY).ui(ui);
+            fn wiggle_btn(ui: &mut Ui, action: &mut Option<WindowAction>) {
+                let res = ui
+                    .scope_builder(
+                        UiBuilder::new().id_salt(WIGGLE_KEY).sense(Sense::click()),
+                        |ui| {
+                            let visuals = *ui.style().interact(&ui.response());
+                            let shortcut_card = shortcut_ui(ui, &WIGGLE_KEY, &ui.response());
+
+                            Frame::canvas(ui.style())
+                                .inner_margin(Margin::symmetric(4, 2))
+                                .outer_margin(0)
+                                .corner_radius(ui.visuals().noninteractive().corner_radius)
+                                .fill(visuals.bg_fill)
+                                .stroke(Stroke::new(1., Color32::TRANSPARENT))
+                                .show(ui, |ui| {
+                                    ui.set_height(ui.available_height());
+                                    ui.horizontal_centered(|ui| {
+                                        ui.set_height(21.);
+                                        ui.add_space(1.);
+                                        Label::new(
+                                            RichText::new("WIGGLE")
+                                                .size(16.)
+                                                .color(visuals.text_color()),
+                                        )
+                                        .selectable(false)
+                                        .ui(ui);
+                                        ui.add(
+                                            Icon::Wiggle
+                                                .as_image(ui.ctx().theme())
+                                                .fit_to_exact_size(Vec2::splat(22.)),
+                                        );
+                                        shortcut_card.ui(ui);
+                                    });
                                 });
-                            });
-                    },
-                );
+                        },
+                    )
+                    .response;
+
+                if res.clicked() {
+                    // set the focus on the aperture field
+                    action.replace(WindowAction::Wiggle);
+                }
+            }
+
+            // valve header
+            let valve_header = |ui: &mut Ui| {
+                ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
+                    Label::new(
+                        RichText::new(self.valve.to_string().to_uppercase())
+                            .color(ui.visuals().strong_text_color())
+                            .size(16.),
+                    )
+                    .ui(ui);
+                    Label::new(RichText::new("VALVE: ").size(16.))
+                        .selectable(false)
+                        .ui(ui);
+                });
             };
 
             ui.with_layout(Layout::centered_and_justified(Direction::TopDown), |ui| {
-                ui.set_max_width(300.);
+                ui.set_max_width(350.);
                 ui.set_min_height(50.);
                 StripBuilder::new(ui)
                     .size(Size::exact(5.))
                     .sizes(Size::initial(5.), 3)
                     .vertical(|mut strip| {
                         strip.empty();
-                        // strip.cell(|ui| {
-                        //     // ui.add_sized(
-                        //     //     Vec2::new(ui.available_width(), 0.0),
-                        //     //     Button::new("Wiggle"),
-                        //     // );
-                        //     let wiggle_btn_response = btn_ui(&self.state, WIGGLE_KEY, |ui| {
-                        //         shortcut_ui(ui, &WIGGLE_KEY).ui(ui);
-                        //         ui.add(
-                        //             Icon::Wiggle
-                        //                 .as_image(ui.ctx().theme())
-                        //                 .fit_to_exact_size(icon_size),
-                        //         );
-                        //         ui.add(
-                        //             Label::new(RichText::new("WIGGLE").size(text_size))
-                        //                 .selectable(false),
-                        //         );
-                        //     })(ui);
-                        // });
                         strip.strip(|builder| {
                             builder
-                                // .size(Size::exact(230.))
-                                .size(Size::initial(10.))
-                                .size(Size::exact(25.))
+                                .size(Size::exact(206.))
+                                .size(Size::initial(50.))
                                 .horizontal(|mut strip| {
                                     strip.strip(|builder| {
                                         builder
@@ -251,35 +240,11 @@ impl ValveControlView {
                                             .size(Size::remainder())
                                             .vertical(|mut strip| {
                                                 strip.empty();
-                                                strip.cell(|ui| {
-                                                    ui.with_layout(
-                                                        Layout::right_to_left(Align::Min),
-                                                        |ui| {
-                                                            Label::new(
-                                                                RichText::new(
-                                                                    self.valve
-                                                                        .to_string()
-                                                                        .to_uppercase(),
-                                                                )
-                                                                .color(
-                                                                    ui.visuals()
-                                                                        .strong_text_color(),
-                                                                )
-                                                                .size(16.),
-                                                            )
-                                                            .ui(ui);
-                                                            Label::new(
-                                                                RichText::new("VALVE: ").size(16.),
-                                                            )
-                                                            .selectable(false)
-                                                            .ui(ui);
-                                                        },
-                                                    );
-                                                });
+                                                strip.cell(valve_header);
                                                 strip.empty();
                                             });
                                     });
-                                    strip.cell(wiggle_btn);
+                                    strip.cell(|ui| wiggle_btn(ui, action));
                                 });
                         });
                         strip.strip(|builder| {
@@ -335,22 +300,66 @@ impl ValveControlView {
                                             .fill(ui.visuals().widgets.inactive.bg_fill)
                                             .stroke(Stroke::new(1., Color32::TRANSPARENT))
                                             .show(ui, |ui| {
+                                                // caveat used to clear the field and fill with the current value
+                                                if let Some(WindowAction::SetAperture) =
+                                                    action.as_ref()
+                                                {
+                                                    ui.ctx().input_mut(|input| {
+                                                        input.events.push(egui::Event::Key {
+                                                            key: Key::A,
+                                                            physical_key: None,
+                                                            pressed: true,
+                                                            repeat: false,
+                                                            modifiers: Modifiers::COMMAND,
+                                                        });
+                                                        input.events.push(egui::Event::Text(
+                                                            self.aperture_perc.to_string(),
+                                                        ));
+                                                        input.events.push(egui::Event::Key {
+                                                            key: Key::A,
+                                                            physical_key: None,
+                                                            pressed: true,
+                                                            repeat: false,
+                                                            modifiers: Modifiers::COMMAND,
+                                                        });
+                                                    });
+                                                }
+
                                                 let res = ui.add_sized(
                                                     Vec2::new(ui.available_width(), 0.0),
                                                     DragValue::new(&mut self.aperture_perc)
                                                         .speed(0.5)
                                                         .range(0.0..=100.0)
                                                         .fixed_decimals(0)
-                                                        .update_while_editing(false)
+                                                        .update_while_editing(true)
                                                         .suffix("%"),
                                                 );
+
+                                                let command_focus = ui.ctx().memory(|m| {
+                                                    m.data.get_temp(aperture_field_focus)
+                                                });
+
+                                                // needed for making sure the state changes even
+                                                // if the pointer clicks inside the field
                                                 if res.gained_focus() {
-                                                    self.state = ValveViewState::ApertureFocused;
+                                                    action.replace(WindowAction::FocusOnAperture);
+                                                } else if res.lost_focus() {
+                                                    action.replace(WindowAction::LooseFocus);
+                                                }
+
+                                                match (command_focus, res.has_focus()) {
+                                                    (Some(true), false) => {
+                                                        res.request_focus();
+                                                    }
+                                                    (Some(false), true) => {
+                                                        res.surrender_focus();
+                                                    }
+                                                    _ => {}
                                                 }
                                             });
                                     });
                                     strip.cell(|ui| {
-                                        aperture_btn(ui);
+                                        show_aperture_btn(&self.state, action, ui);
                                     });
                                 });
                         });
@@ -404,99 +413,75 @@ impl ValveControlView {
                                             .fill(ui.visuals().widgets.inactive.bg_fill)
                                             .stroke(Stroke::new(1., Color32::TRANSPARENT))
                                             .show(ui, |ui| {
-                                                ui.add_sized(
+                                                // caveat used to clear the field and fill with the current value
+                                                if let Some(WindowAction::SetTiming) =
+                                                    action.as_ref()
+                                                {
+                                                    ui.ctx().input_mut(|input| {
+                                                        input.events.push(egui::Event::Key {
+                                                            key: Key::A,
+                                                            physical_key: None,
+                                                            pressed: true,
+                                                            repeat: false,
+                                                            modifiers: Modifiers::COMMAND,
+                                                        });
+                                                        input.events.push(egui::Event::Text(
+                                                            self.timing_ms.to_string(),
+                                                        ));
+                                                        input.events.push(egui::Event::Key {
+                                                            key: Key::A,
+                                                            physical_key: None,
+                                                            pressed: true,
+                                                            repeat: false,
+                                                            modifiers: Modifiers::COMMAND,
+                                                        });
+                                                    });
+                                                }
+
+                                                let res = ui.add_sized(
                                                     Vec2::new(ui.available_width(), 0.0),
                                                     DragValue::new(&mut self.timing_ms)
                                                         .speed(1)
                                                         .range(1..=10000)
                                                         .fixed_decimals(0)
-                                                        .update_while_editing(false)
+                                                        .update_while_editing(true)
                                                         .suffix(" [ms]"),
                                                 );
+
+                                                let command_focus = ui.ctx().memory(|m| {
+                                                    m.data.get_temp(timing_field_focus)
+                                                });
+
+                                                // needed for making sure the state changes even
+                                                // if the pointer clicks inside the field
+                                                if res.gained_focus() {
+                                                    action.replace(WindowAction::FocusOnTiming);
+                                                } else if res.lost_focus() {
+                                                    action.replace(WindowAction::LooseFocus);
+                                                }
+
+                                                match (command_focus, res.has_focus()) {
+                                                    (Some(true), false) => {
+                                                        res.request_focus();
+                                                    }
+                                                    (Some(false), true) => {
+                                                        res.surrender_focus();
+                                                    }
+                                                    _ => {}
+                                                }
                                             });
                                     });
                                     strip.cell(|ui| {
-                                        timing_btn(ui);
+                                        show_timing_btn(&self.state, action, ui);
                                     });
                                 });
                         });
                     });
             });
-
-            // ui.horizontal(|ui| {
-            //     let wiggle_btn_response = btn_ui(&self.state, WIGGLE_KEY, |ui| {
-            //         shortcut_ui(ui, &WIGGLE_KEY).ui(ui);
-            //         ui.add(
-            //             Icon::Wiggle
-            //                 .as_image(ui.ctx().theme())
-            //                 .fit_to_exact_size(icon_size),
-            //         );
-            //         ui.add(Label::new(RichText::new("Wiggle").size(text_size)).selectable(false));
-            //     })(ui);
-
-            //     let aperture_btn_response = btn_ui(&self.state, APERTURE_KEY, |ui| {
-            //         shortcut_ui(ui, &APERTURE_KEY).ui(ui);
-            //         ui.add(
-            //             Icon::Aperture
-            //                 .as_image(ui.ctx().theme())
-            //                 .fit_to_exact_size(icon_size),
-            //         );
-            //         ui.add(
-            //             Label::new(RichText::new("Aperture: ").size(text_size)).selectable(false),
-            //         );
-            //         let drag_value_id = ui.next_auto_id();
-            //         ui.add(
-            //             DragValue::new(&mut self.aperture_perc)
-            //                 .speed(0.5)
-            //                 .range(0.0..=100.0)
-            //                 .fixed_decimals(0)
-            //                 .update_while_editing(false)
-            //                 .suffix("%"),
-            //         );
-            //         if matches!(&self.state, ValveViewState::ApertureFocused) {
-            //             ui.ctx().memory_mut(|m| {
-            //                 m.request_focus(drag_value_id);
-            //             });
-            //         }
-            //     })(ui);
-
-            //     let timing_btn_response = btn_ui(&self.state, TIMING_KEY, |ui| {
-            //         shortcut_ui(ui, &TIMING_KEY).ui(ui);
-            //         ui.add(
-            //             Icon::Timing
-            //                 .as_image(ui.ctx().theme())
-            //                 .fit_to_exact_size(icon_size),
-            //         );
-            //         ui.add(Label::new(RichText::new("Timing: ").size(text_size)).selectable(false));
-            //         let drag_value_id = ui.next_auto_id();
-            //         ui.add(
-            //             DragValue::new(&mut self.timing_ms)
-            //                 .speed(1)
-            //                 .range(1..=10000)
-            //                 .fixed_decimals(0)
-            //                 .update_while_editing(false)
-            //                 .suffix(" [ms]"),
-            //         );
-            //         if matches!(&self.state, ValveViewState::TimingFocused) {
-            //             ui.ctx().memory_mut(|m| {
-            //                 m.request_focus(drag_value_id);
-            //             });
-            //         }
-            //     })(ui);
-
-            //     // consider that action may be different that null if a keyboard shortcut was captured
-            //     if wiggle_btn_response.clicked() {
-            //         action.replace(WindowAction::Wiggle);
-            //     } else if aperture_btn_response.clicked() {
-            //         action.replace(WindowAction::SetAperture);
-            //     } else if timing_btn_response.clicked() {
-            //         action.replace(WindowAction::SetTiming);
-            //     }
-            // });
         }
     }
 
-    fn handle_actions(&mut self, action: Option<WindowAction>) -> Option<Command> {
+    fn handle_actions(&mut self, action: Option<WindowAction>, ctx: &Context) -> Option<Command> {
         match action {
             // If the action close is called, close the window
             Some(WindowAction::CloseWindow) => {
@@ -505,6 +490,12 @@ impl ValveControlView {
             }
             Some(WindowAction::LooseFocus) => {
                 self.state = ValveViewState::Open;
+                let aperture_field_focus = self.id.with("aperture_field_focus");
+                let timing_field_focus = self.id.with("timing_field_focus");
+                ctx.memory_mut(|m| {
+                    m.data.insert_temp(aperture_field_focus, false);
+                    m.data.insert_temp(timing_field_focus, false);
+                });
                 None
             }
             Some(WindowAction::Wiggle) => {
@@ -516,7 +507,7 @@ impl ValveControlView {
                     "Issued command to set timing for valve {:?} to {} ms",
                     self.valve, self.timing_ms
                 );
-                self.state = ValveViewState::Open;
+                self.handle_actions(Some(WindowAction::LooseFocus), ctx);
                 Some(Command::set_atomic_valve_timing(self.valve, self.timing_ms))
             }
             Some(WindowAction::SetAperture) => {
@@ -524,7 +515,7 @@ impl ValveControlView {
                     "Issued command to set aperture for valve {:?} to {}%",
                     self.valve, self.aperture_perc
                 );
-                self.state = ValveViewState::Open;
+                self.handle_actions(Some(WindowAction::LooseFocus), ctx);
                 Some(Command::set_valve_maximum_aperture(
                     self.valve,
                     self.aperture_perc / 100.,
@@ -532,10 +523,18 @@ impl ValveControlView {
             }
             Some(WindowAction::FocusOnTiming) => {
                 self.state = ValveViewState::TimingFocused;
+                let timing_field_focus = self.id.with("timing_field_focus");
+                ctx.memory_mut(|m| {
+                    m.data.insert_temp(timing_field_focus, true);
+                });
                 None
             }
             Some(WindowAction::FocusOnAperture) => {
                 self.state = ValveViewState::ApertureFocused;
+                let aperture_field_focus = self.id.with("aperture_field_focus");
+                ctx.memory_mut(|m| {
+                    m.data.insert_temp(aperture_field_focus, true);
+                });
                 None
             }
             _ => None,
@@ -553,22 +552,26 @@ impl ValveControlView {
             ValveViewState::Open => {
                 // A window is open, so we can map the keys to control the valve
                 key_action_pairs.push((Modifiers::NONE, WIGGLE_KEY, WindowAction::Wiggle));
-                // key_action_pairs.push((Modifiers::NONE, TIMING_KEY, WindowAction::FocusOnTiming));
-                // key_action_pairs.push((
-                //     Modifiers::NONE,
-                //     APERTURE_KEY,
-                //     WindowAction::FocusOnAperture,
-                // ));
+                key_action_pairs.push((
+                    Modifiers::NONE,
+                    FOCUS_TIMING_KEY,
+                    WindowAction::FocusOnTiming,
+                ));
+                key_action_pairs.push((
+                    Modifiers::NONE,
+                    FOCUS_APERTURE_KEY,
+                    WindowAction::FocusOnAperture,
+                ));
                 key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::CloseWindow));
             }
             ValveViewState::TimingFocused => {
                 // The timing field is focused, so we can map the keys to control the timing
-                key_action_pairs.push((Modifiers::NONE, Key::Enter, WindowAction::SetTiming));
+                key_action_pairs.push((Modifiers::NONE, SET_PAR_KEY, WindowAction::SetTiming));
                 key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::LooseFocus));
             }
             ValveViewState::ApertureFocused => {
                 // The aperture field is focused, so we can map the keys to control the aperture
-                key_action_pairs.push((Modifiers::NONE, Key::Enter, WindowAction::SetAperture));
+                key_action_pairs.push((Modifiers::NONE, SET_PAR_KEY, WindowAction::SetAperture));
                 key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::LooseFocus));
             }
             ValveViewState::Closed => {}
@@ -585,13 +588,6 @@ enum ValveViewState {
     ApertureFocused,
 }
 
-impl ValveViewState {
-    #[inline]
-    fn is_open(&self) -> bool {
-        matches!(self, Self::Open)
-    }
-}
-
 #[derive(Debug, Clone, Copy)]
 enum WindowAction {
     // window actions
-- 
GitLab


From 0c10a034214cf8df3e90950f9aef78e29d3f2af5 Mon Sep 17 00:00:00 2001
From: Federico Lolli <federico.lolli@skywarder.eu>
Date: Fri, 11 Apr 2025 18:22:01 +0200
Subject: [PATCH 24/24] fixed button clicking with keyboard shortcut

---
 .../valve_control/ui/valve_control_window.rs  | 36 ++++++++++++++-----
 1 file changed, 28 insertions(+), 8 deletions(-)

diff --git a/src/ui/panes/valve_control/ui/valve_control_window.rs b/src/ui/panes/valve_control/ui/valve_control_window.rs
index 470b6a2..c1c71f0 100644
--- a/src/ui/panes/valve_control/ui/valve_control_window.rs
+++ b/src/ui/panes/valve_control/ui/valve_control_window.rs
@@ -92,9 +92,15 @@ impl ValveControlView {
                     .text_size(12.)
             }
 
-            fn add_parameter_btn(ui: &mut Ui, key: Key) -> Response {
+            fn add_parameter_btn(ui: &mut Ui, key: Key, action_override: bool) -> Response {
                 ui.scope_builder(UiBuilder::new().id_salt(key).sense(Sense::click()), |ui| {
-                    let visuals = *ui.style().interact(&ui.response());
+                    let mut visuals = *ui.style().interact(&ui.response());
+
+                    // override the visuals if the button is pressed
+                    if action_override {
+                        visuals = ui.visuals().widgets.active;
+                    }
+
                     let shortcut_card = shortcut_ui(ui, &key, &ui.response());
 
                     Frame::canvas(ui.style())
@@ -127,8 +133,12 @@ impl ValveControlView {
                 ui: &mut Ui,
             ) -> Response {
                 let res = match state {
-                    ValveViewState::Open => Some(add_parameter_btn(ui, FOCUS_APERTURE_KEY)),
-                    ValveViewState::ApertureFocused => Some(add_parameter_btn(ui, SET_PAR_KEY)),
+                    ValveViewState::Open => Some(add_parameter_btn(ui, FOCUS_APERTURE_KEY, false)),
+                    ValveViewState::ApertureFocused => Some(add_parameter_btn(
+                        ui,
+                        SET_PAR_KEY,
+                        action.is_some_and(|a| a == WindowAction::SetAperture),
+                    )),
                     ValveViewState::TimingFocused | ValveViewState::Closed => None,
                 };
                 if let Some(res) = &res {
@@ -147,8 +157,12 @@ impl ValveControlView {
                 ui: &mut Ui,
             ) -> Response {
                 let res = match state {
-                    ValveViewState::Open => Some(add_parameter_btn(ui, FOCUS_TIMING_KEY)),
-                    ValveViewState::TimingFocused => Some(add_parameter_btn(ui, SET_PAR_KEY)),
+                    ValveViewState::Open => Some(add_parameter_btn(ui, FOCUS_TIMING_KEY, false)),
+                    ValveViewState::TimingFocused => Some(add_parameter_btn(
+                        ui,
+                        SET_PAR_KEY,
+                        action.is_some_and(|a| a == WindowAction::SetTiming),
+                    )),
                     ValveViewState::ApertureFocused | ValveViewState::Closed => None,
                 };
                 if let Some(res) = &res {
@@ -166,7 +180,13 @@ impl ValveControlView {
                     .scope_builder(
                         UiBuilder::new().id_salt(WIGGLE_KEY).sense(Sense::click()),
                         |ui| {
-                            let visuals = *ui.style().interact(&ui.response());
+                            let mut visuals = *ui.style().interact(&ui.response());
+
+                            // override the visuals if the button is pressed
+                            if let Some(WindowAction::Wiggle) = action.as_ref() {
+                                visuals = ui.visuals().widgets.active;
+                            }
+
                             let shortcut_card = shortcut_ui(ui, &WIGGLE_KEY, &ui.response());
 
                             Frame::canvas(ui.style())
@@ -588,7 +608,7 @@ enum ValveViewState {
     ApertureFocused,
 }
 
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 enum WindowAction {
     // window actions
     CloseWindow,
-- 
GitLab