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