diff --git a/Cargo.lock b/Cargo.lock
index f65be72dd8a36f77a84c7ba7bc933340affd0db8..28368be87bb987a9f8b10b3c370ddb86c957a1d4 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 8c2de1bc18277d46b2809a58c2c77efa82b714e7..b049c64d00713a2f96c236920111dd83eb989398 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/icons/valve_control/dark/aperture.svg b/icons/valve_control/dark/aperture.svg
new file mode 100644
index 0000000000000000000000000000000000000000..55195e2d89f6392c66d95d1caf4a70966cad0a00
--- /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="#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" />
+</svg>
diff --git a/icons/valve_control/dark/timing.svg b/icons/valve_control/dark/timing.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a9d7e365e0bc5a0931c6788e1f414755e4f1a926
--- /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="#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/dark/wiggle.svg b/icons/valve_control/dark/wiggle.svg
new file mode 100644
index 0000000000000000000000000000000000000000..f033a58e4a904951697bb1e001a560cc6c40e3d7
--- /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/aperture.svg b/icons/valve_control/light/aperture.svg
new file mode 100644
index 0000000000000000000000000000000000000000..df466969d96d5067ac14d5afac9a1ba4d0d9e773
--- /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="#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" />
+</svg>
diff --git a/icons/valve_control/light/timing.svg b/icons/valve_control/light/timing.svg
new file mode 100644
index 0000000000000000000000000000000000000000..49bcce67ad26eebf4352408beb8f3c87bd41276b
--- /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="#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/icons/valve_control/light/wiggle.svg b/icons/valve_control/light/wiggle.svg
new file mode 100644
index 0000000000000000000000000000000000000000..864577ec13906b906d6217c335ca58e493b88a66
--- /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/mavlink.rs b/src/mavlink.rs
index f1517ccf525029cb9782ba848669ab65611fb369..9a05aa884935d8e1f278f356abc7ecc16faf320f 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 7cf10e703a48e9144e2d6577723b453930ceddd8..ddf1c1717dbeb0849581b28dc43f2c33d5cdad9c 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 52568b935f267e0af070423a746299ddba2151c7..eaa06df7254819bf6e9c348c094343a589cb7f97 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 ad8fa9fe0387921d5596236afa2f415efecbe8e5..b83784b0a4fef6e196444432cc2065559999c63e 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,
@@ -49,11 +53,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
@@ -63,21 +63,23 @@ 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(Some(hovered_tile)),
-                ),
-                ((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[..]));
+            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
-        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 +134,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 +172,6 @@ impl eframe::App for App {
                         self.maximized_pane = None;
                     }
                 }
-                _ => panic!("Unable to handle action"),
             }
         }
 
@@ -225,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, maximized_pane, 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);
@@ -281,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(),
         }
@@ -318,14 +325,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());
             }
         }
 
@@ -400,9 +405,20 @@ impl AppState {
 }
 
 /// Behavior for the tree of panes in the app
-#[derive(Default)]
 pub struct AppBehavior {
-    pub action: Option<PaneAction>,
+    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 {
@@ -412,13 +428,20 @@ impl Behavior<Pane> for AppBehavior {
         tile_id: TileId,
         pane: &mut Pane,
     ) -> egui_tiles::UiResponse {
+        let res = ui.scope(|ui| pane.ui(ui, self.shortcut_handler.lock().log_unwrap().deref_mut()));
         let PaneResponse {
             action_called,
             drag_response,
-        } = pane.ui(ui, tile_id);
+        } = 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(action_called);
+            self.action = Some((tile_id, action_called));
         }
         drag_response
     }
@@ -458,8 +481,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 977fbda1ee08dd7f9fac4c989573e74c1fbf8e96..3fa948cf62bacc3dca440f5c3a739ba576a64aaa 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -1,16 +1,17 @@
 mod default;
 mod messages_viewer;
 mod pid_drawing_tool;
-pub mod plot;
+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};
 
 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 {
@@ -26,18 +27,15 @@ 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;
-
-    /// Whether the pane contains the pointer.
-    fn contains_pointer(&self) -> bool;
+    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.
-    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.
@@ -52,20 +50,16 @@ 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 contains_pointer(&self) -> bool {
-        self.pane.contains_pointer()
+    fn ui(&mut self, ui: &mut Ui, shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
+        self.pane.ui(ui, shortcut_handler)
     }
 
-    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 {
@@ -90,7 +84,10 @@ pub enum PaneKind {
     Plot2D(plot::Plot2DPane),
 
     #[strum(message = "Pid")]
-    PidOld(pid_drawing_tool::PidPane),
+    Pid(pid_drawing_tool::PidPane),
+
+    #[strum(message = "Valve Control")]
+    ValveControl(valve_control::ValveControlPane),
 }
 
 impl Default for PaneKind {
diff --git a/src/ui/panes/default.rs b/src/ui/panes/default.rs
index c3f7979e5ec83ea72df13087940f2fec997af02b..dd2406f6c54c63c157ac4789dcd69bfdccd58443 100644
--- a/src/ui/panes/default.rs
+++ b/src/ui/panes/default.rs
@@ -1,10 +1,15 @@
 use super::PaneBehavior;
+use egui::Ui;
 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},
+        shortcuts::ShortcutHandler,
+        utils::{SizingMemo, vertically_centered},
+    },
 };
 
 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
@@ -23,7 +28,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, _shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
         let mut response = PaneResponse::default();
 
         let parent = vertically_centered(ui, &mut self.centering_memo, |ui| {
@@ -37,7 +42,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
@@ -45,25 +50,17 @@ 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();
         };
 
         response
     }
 
-    fn contains_pointer(&self) -> bool {
-        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/messages_viewer.rs b/src/ui/panes/messages_viewer.rs
index 2c8a54f5e0c796dba09e932fee105fc99ee07ed1..eb964669f6b83b39e090b230501d222eac7f8c45 100644
--- a/src/ui/panes/messages_viewer.rs
+++ b/src/ui/panes/messages_viewer.rs
@@ -1,35 +1,21 @@
-use egui::Label;
+use egui::{Label, Ui};
 use serde::{Deserialize, Serialize};
 
-use crate::ui::app::PaneResponse;
+use crate::ui::{app::PaneResponse, shortcuts::ShortcutHandler};
 
 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 egui::Ui, _tile_id: egui_tiles::TileId) -> 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"));
-        self.contains_pointer = label.contains_pointer();
         if label.drag_started() {
             response.set_drag_started();
         }
         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 f4e980e4182d1418efbe1ca2fbf104a10ed3f4d7..9257cf44ec076c1efda702e8f2c1e46b3199720c 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;
@@ -20,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;
@@ -80,7 +81,9 @@ 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, _shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
+        let mut pane_response = PaneResponse::default();
+
         let theme = PidPane::find_theme(ui.ctx());
 
         if self.center_content && !self.editable {
@@ -132,14 +135,16 @@ impl PaneBehavior for PidPane {
             self.reset_subscriptions();
         }
 
-        PaneResponse::default()
-    }
+        // Check if the user is draqging the pane
+        let ctrl_pressed = ui.input(|i| i.modifiers.ctrl);
+        if response.dragged() && (ctrl_pressed || !self.editable) {
+            pane_response.set_drag_started();
+        }
 
-    fn contains_pointer(&self) -> bool {
-        false
+        pane_response
     }
 
-    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);
@@ -147,8 +152,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 4a98372c1fd099015a2774a335a285fef93b3e2b..e4616d1532f060623372344818eeb051332ab105 100644
--- a/src/ui/panes/plot.rs
+++ b/src/ui/panes/plot.rs
@@ -8,12 +8,11 @@ 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, 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::{
@@ -32,8 +31,6 @@ pub struct Plot2DPane {
     state_valid: bool,
     #[serde(skip)]
     settings_visible: bool,
-    #[serde(skip)]
-    pub contains_pointer: bool,
 }
 
 impl PartialEq for Plot2DPane {
@@ -44,7 +41,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, _shortcut_handler: &mut ShortcutHandler) -> PaneResponse {
         let mut response = PaneResponse::default();
         let data_settings_digest = self.settings.data_digest();
 
@@ -146,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();
             }
@@ -181,12 +177,8 @@ impl PaneBehavior for Plot2DPane {
         response
     }
 
-    fn contains_pointer(&self) -> bool {
-        self.contains_pointer
-    }
-
     #[profiling::function]
-    fn update(&mut self, messages: &[TimedMessage]) {
+    fn update(&mut self, messages: &[&TimedMessage]) {
         if !self.state_valid {
             self.line_data.clear();
         }
@@ -226,8 +218,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 {
@@ -235,7 +227,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/plot/source_window.rs b/src/ui/panes/plot/source_window.rs
index a62d1f9e41bcb3154c7bd4197327fc62c37a7306..c3098783f3ccad53e7ebf0e8e1b4c49ef381e447 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
new file mode 100644
index 0000000000000000000000000000000000000000..22a86e659d1631a347b596b51b5cd8cab1b7ecd3
--- /dev/null
+++ b/src/ui/panes/valve_control.rs
@@ -0,0 +1,473 @@
+mod commands;
+mod icons;
+mod ui;
+mod valves;
+
+use std::{
+    collections::HashMap,
+    time::{Duration, Instant},
+};
+
+use egui::{
+    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};
+use skyward_mavlink::{
+    mavlink::MessageData,
+    orion::{ACK_TM_DATA, NACK_TM_DATA, WACK_TM_DATA},
+};
+use strum::IntoEnumIterator;
+use tracing::info;
+
+use crate::{
+    mavlink::{MavMessage, TimedMessage},
+    ui::{
+        app::PaneResponse,
+        shortcuts::{ShortcutHandler, ShortcutMode},
+    },
+};
+
+use super::PaneBehavior;
+
+use commands::CommandSM;
+use icons::Icon;
+use ui::{ShortcutCard, ValveControlView, map_key_to_shortcut};
+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
+    #[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,
+    #[serde(skip)]
+    valve_key_map: HashMap<Valve, Key>,
+    #[serde(skip)]
+    valve_view: Option<ValveControlView>,
+}
+
+impl Default for ValveControlPane {
+    fn default() -> Self {
+        let symbols: Vec<char> = SYMBOL_LIST.chars().collect();
+        let valve_key_map = Valve::iter()
+            .zip(symbols.into_iter().map(map_symbol_to_key))
+            .collect();
+        Self {
+            valves_state: ValveStateManager::default(),
+            commands: vec![],
+            auto_refresh: None,
+            manual_refresh: false,
+            last_refresh: None,
+            is_settings_window_open: false,
+            valve_key_map,
+            valve_view: None,
+        }
+    }
+}
+
+impl PaneBehavior for ValveControlPane {
+    #[profiling::function]
+    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));
+
+        if let Some(valve_view) = &mut self.valve_view {
+            if let Some(command) = valve_view.ui(ui, shortcut_handler) {
+                self.commands.push(command.into());
+            }
+
+            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();
+            }
+
+            // capture actions from keyboard shortcuts
+            let action = self.keyboard_actions(shortcut_handler);
+
+            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,
+                        ui.id().with(valve.to_string()),
+                    ));
+                }
+                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
+    }
+
+    #[profiling::function]
+    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())
+    }
+
+    #[profiling::function]
+    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, Some(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();
+    }
+
+    #[profiling::function]
+    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       │
+// └────────────────────────┘
+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));
+            Grid::new("valves_grid")
+                .num_columns(n)
+                .spacing(Vec2::splat(5.))
+                .show(ui, |ui| {
+                    for chunk in &valve_chunks {
+                        for (symbol, valve) in chunk {
+                            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_view = Some(ValveControlView::new(
+                                    valve,
+                                    ui.id().with(valve.to_string()),
+                                ));
+                            }
+                        }
+                        ui.end_row();
+                    }
+                });
+        }
+    }
+
+    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();
+            }
+            if ui.button("Settings").clicked() {
+                self.is_settings_window_open = true;
+                ui.close_menu();
+            }
+        }
+    }
+
+    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| {
+                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_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)
+                        .prefix("Every ")
+                        .suffix(" s")
+                        .ui(ui);
+                    *auto_refresh_duration = Duration::from_secs_f32(auto_refresh_period);
+                } else {
+                    *auto_refresh_setting = None;
+                }
+            });
+        }
+    }
+
+    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();
+            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!("{:.2}%", value * 100.)
+                }
+                valves::ParameterValue::Missing => "N/A".to_owned(),
+                valves::ParameterValue::Invalid(err_id) => {
+                    format!("ERROR({})", err_id)
+                }
+            };
+            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);
+            };
+
+            let labels_ui = |ui: &mut Ui| {
+                let icon_size = Vec2::splat(17.);
+                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.set_min_width(80.);
+                    ui.horizontal_top(|ui| {
+                        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());
+                            let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
+                            Label::new(galley).selectable(false).ui(ui);
+                        });
+                    });
+                    ui.horizontal_top(|ui| {
+                        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));
+                        Label::new(galley).selectable(false).ui(ui);
+                    });
+                });
+            };
+
+            ui.scope_builder(
+                UiBuilder::new()
+                    .id_salt("valve_".to_owned() + &valve_str)
+                    .sense(Sense::click()),
+                |ui| {
+                    let response = ui.response();
+                    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_view.is_none()
+                    {
+                        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| {
+                            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)
+                        .inner_margin(ui.spacing().menu_margin)
+                        .corner_radius(visuals.corner_radius)
+                        .show(ui, inside_frame);
+                },
+            )
+            .response
+        }
+    }
+
+    #[profiling::function]
+    fn keyboard_actions(&self, shortcut_handler: &mut ShortcutHandler) -> Option<PaneAction> {
+        let mut key_action_pairs = Vec::new();
+        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[..])
+    }
+}
+
+// ┌───────────────────────────┐
+// │       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;
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+enum PaneAction {
+    OpenValveControl(Valve),
+}
diff --git a/src/ui/panes/valve_control/commands.rs b/src/ui/panes/valve_control/commands.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ed4d282dfe6ed98650e67ebaa07a50d80ba0639a
--- /dev/null
+++ b/src/ui/panes/valve_control/commands.rs
@@ -0,0 +1,203 @@
+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,
+};
+
+use super::valves::{ParameterValue, Valve, ValveParameter};
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum CommandSM {
+    Request(Command),
+    WaitingForResponse((Instant, Command)),
+    Response((Valve, Option<ValveParameter>)),
+    Consumed,
+}
+
+impl CommandSM {
+    pub fn pack_and_wait(&mut self) -> Option<MavMessage> {
+        match self {
+            Self::Request(command) => {
+                let message = MavMessage::from(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 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 => {
+                    *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, Option<ValveParameter>)> {
+        match self {
+            Self::Response((valve, parameter)) => {
+                let res = Some((*valve, parameter.clone()));
+                *self = CommandSM::Consumed;
+                res
+            }
+            _ => None,
+        }
+    }
+
+    pub fn is_waiting_for_response(&self) -> bool {
+        matches!(self, Self::WaitingForResponse(_))
+    }
+
+    pub fn is_consumed(&self) -> bool {
+        matches!(self, Self::Consumed)
+    }
+}
+
+impl From<Command> for CommandSM {
+    fn from(value: Command) -> Self {
+        Self::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 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) => {
+                Self::SET_ATOMIC_VALVE_TIMING_TC(SET_ATOMIC_VALVE_TIMING_TC_DATA {
+                    servo_id: value.valve.into(),
+                    maximum_timing: timing,
+                })
+            }
+            CommandKind::SetValveMaximumAperture(aperture) => {
+                Self::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 {
+    Wiggle,
+    SetAtomicValveTiming(u32),
+    SetValveMaximumAperture(f32),
+}
+
+impl CommandKind {
+    fn message_id(&self) -> u32 {
+        match self {
+            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) -> Option<ValveParameter> {
+        (*self).try_into().ok()
+    }
+
+    fn to_invalid_parameter(&self, error: u16) -> Option<ValveParameter> {
+        match self {
+            Self::Wiggle => None,
+            Self::SetAtomicValveTiming(_) => Some(ValveParameter::AtomicValveTiming(
+                ParameterValue::Invalid(error),
+            )),
+            Self::SetValveMaximumAperture(_) => Some(ValveParameter::ValveMaximumAperture(
+                ParameterValue::Invalid(error),
+            )),
+        }
+    }
+}
+
+impl TryFrom<CommandKind> for ValveParameter {
+    type Error = ();
+
+    fn try_from(value: CommandKind) -> Result<Self, Self::Error> {
+        match value {
+            CommandKind::Wiggle => Err(()),
+            CommandKind::SetAtomicValveTiming(timing) => {
+                Ok(Self::AtomicValveTiming(ParameterValue::Valid(timing)))
+            }
+            CommandKind::SetValveMaximumAperture(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
new file mode 100644
index 0000000000000000000000000000000000000000..654acae3b8172661da4d46329b42b65648a41e54
--- /dev/null
+++ b/src/ui/panes/valve_control/icons.rs
@@ -0,0 +1,70 @@
+use egui::{Context, Image, ImageSource, SizeHint, TextureOptions, Theme};
+use strum::IntoEnumIterator;
+use strum_macros::EnumIter;
+use tracing::error;
+
+#[derive(Debug, Clone, Copy, EnumIter)]
+pub enum Icon {
+    Wiggle,
+    Aperture,
+    Timing,
+}
+
+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"),
+                    "/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"
+                ))
+            }
+        }
+    }
+
+    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))
+    }
+}
diff --git a/src/ui/panes/valve_control/ui.rs b/src/ui/panes/valve_control/ui.rs
new file mode 100644
index 0000000000000000000000000000000000000000..6350f617f08e550293b963b099be1f6bb0b7d378
--- /dev/null
+++ b/src/ui/panes/valve_control/ui.rs
@@ -0,0 +1,14 @@
+mod shortcut_widget;
+mod valve_control_window;
+
+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::ValveControlView};
+
+#[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 0000000000000000000000000000000000000000..212d8954cd17cb1f3dccd1fccae87c5c3d945b14
--- /dev/null
+++ b/src/ui/panes/valve_control/ui/shortcut_widget.rs
@@ -0,0 +1,73 @@
+use egui::{
+    Color32, FontId, Frame, KeyboardShortcut, Label, Margin, ModifierNames, RichText, Stroke,
+    Widget,
+};
+
+pub struct ShortcutCard {
+    shortcut: KeyboardShortcut,
+    text_size: f32,
+    margin: Margin,
+    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(self.margin)
+            .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.,
+            margin: Margin::same(5),
+            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
+    }
+
+    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
new file mode 100644
index 0000000000000000000000000000000000000000..c1c71f0690a7e0a1207e1701ba44ae580a4deafa
--- /dev/null
+++ b/src/ui/panes/valve_control/ui/valve_control_window.rs
@@ -0,0 +1,623 @@
+use egui::{
+    Align, Color32, Context, Direction, DragValue, Frame, Id, Key, Label, Layout, Margin,
+    Modifiers, Response, RichText, Sense, Stroke, Ui, UiBuilder, Vec2, Widget,
+};
+use egui_extras::{Size, StripBuilder};
+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;
+/// 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 ValveControlView {
+    valve: Valve,
+    state: ValveViewState,
+    timing_ms: u32,
+    aperture_perc: f32,
+    id: Id,
+}
+
+impl ValveControlView {
+    pub fn new(valve: Valve, id: Id) -> ValveControlView {
+        ValveControlView {
+            valve,
+            state: ValveViewState::Open,
+            timing_ms: 0,
+            aperture_perc: 0.0,
+            id,
+        }
+    }
+
+    pub fn is_closed(&self) -> bool {
+        matches!(self.state, ValveViewState::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 view inside the pane
+        ui.scope(self.draw_view_ui(&mut action));
+
+        // Handle the actions
+        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 aperture_field_focus = self.id.with("aperture_field_focus");
+            let timing_field_focus = self.id.with("timing_field_focus");
+
+            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, 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(uvis.bg_fill))
+                    .margin(Margin::symmetric(5, 2))
+                    .text_size(12.)
+            }
+
+            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 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())
+                        .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("SET").size(16.).color(visuals.text_color()),
+                                )
+                                .selectable(false)
+                                .ui(ui);
+                                shortcut_card.ui(ui);
+                            });
+                        });
+                })
+                .response
+            }
+
+            // set aperture and timing buttons
+            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, 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 {
+                    if res.clicked() {
+                        // set the focus on the aperture field
+                        action.replace(WindowAction::SetAperture);
+                    }
+                }
+                res.unwrap_or_else(|| ui.response())
+            }
+
+            // set timing button
+            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, 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 {
+                    if res.clicked() {
+                        // set the focus on the aperture field
+                        action.replace(WindowAction::SetTiming);
+                    }
+                }
+                res.unwrap_or_else(|| ui.response())
+            }
+
+            // wiggle button with shortcut
+            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 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())
+                                .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(350.);
+                ui.set_min_height(50.);
+                StripBuilder::new(ui)
+                    .size(Size::exact(5.))
+                    .sizes(Size::initial(5.), 3)
+                    .vertical(|mut strip| {
+                        strip.empty();
+                        strip.strip(|builder| {
+                            builder
+                                .size(Size::exact(206.))
+                                .size(Size::initial(50.))
+                                .horizontal(|mut strip| {
+                                    strip.strip(|builder| {
+                                        builder
+                                            .size(Size::remainder())
+                                            .size(Size::initial(5.))
+                                            .size(Size::remainder())
+                                            .vertical(|mut strip| {
+                                                strip.empty();
+                                                strip.cell(valve_header);
+                                                strip.empty();
+                                            });
+                                    });
+                                    strip.cell(|ui| wiggle_btn(ui, action));
+                                });
+                        });
+                        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| {
+                                                // 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(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() {
+                                                    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| {
+                                        show_aperture_btn(&self.state, action, 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| {
+                                                // 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(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| {
+                                        show_timing_btn(&self.state, action, ui);
+                                    });
+                                });
+                        });
+                    });
+            });
+        }
+    }
+
+    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) => {
+                self.state = ValveViewState::Closed;
+                None
+            }
+            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) => {
+                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.handle_actions(Some(WindowAction::LooseFocus), ctx);
+                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.handle_actions(Some(WindowAction::LooseFocus), ctx);
+                Some(Command::set_valve_maximum_aperture(
+                    self.valve,
+                    self.aperture_perc / 100.,
+                ))
+            }
+            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,
+        }
+    }
+}
+
+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 {
+            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,
+                    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, 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, SET_PAR_KEY, WindowAction::SetAperture));
+                key_action_pairs.push((Modifiers::NONE, Key::Escape, WindowAction::LooseFocus));
+            }
+            ValveViewState::Closed => {}
+        }
+        shortcut_handler.consume_if_mode_is(ShortcutMode::valve_control(), &key_action_pairs[..])
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ValveViewState {
+    Closed,
+    Open,
+    TimingFocused,
+    ApertureFocused,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum WindowAction {
+    // window actions
+    CloseWindow,
+    LooseFocus,
+    // commands
+    Wiggle,
+    SetTiming,
+    SetAperture,
+    // UI focus
+    FocusOnTiming,
+    FocusOnAperture,
+}
diff --git a/src/ui/panes/valve_control/valves.rs b/src/ui/panes/valve_control/valves.rs
new file mode 100644
index 0000000000000000000000000000000000000000..cf9b15a575e7d46e58a4545474bb13908d3e983d
--- /dev/null
+++ b/src/ui/panes/valve_control/valves.rs
@@ -0,0 +1,149 @@
+//! 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, Hash)]
+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, 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 {
+            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 7efa69f2ac6fc1c01bf85380dc7b44437bb55e5a..d95bcaf8d63d2c12872ce2fe24354aa7721c21fa 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 0c2ae3f7257254b414d9974507003ee1443a30bf..7d1b19344d0c505be71491a1e4976e58f6912fb2 100644
--- a/src/ui/utils.rs
+++ b/src/ui/utils.rs
@@ -1,17 +1,19 @@
 use egui::containers::Frame;
 use egui::{Response, Shadow, Stroke, Style, Ui};
-use egui_tiles::TileId;
 
-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, tile_id: TileId, 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, tile_id));
+        .show(ui, |ui| pane.ui(ui, shortcut_handler));
 }
 
 #[derive(Debug, Default, Clone)]
diff --git a/src/ui/widget_gallery.rs b/src/ui/widget_gallery.rs
index ad8796d55159d2cf83b10f73a0db4692bedf6506..80ef01ebcb7e0c8e45b14e0c9dae23e1875743ab 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))));
                             }
                         }
                     }