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

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

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