Repository: Leafwing-Studios/leafwing-input-manager
Pull request #263 (2022-10-13 17:27:14)

Release 0.6
Check the milestone for plans!


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6ab333e..b22e39e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ jobs:
     strategy:
       matrix:
         toolchain: [stable]
-        os: [windows-latest]
+        os: [ubuntu-latest]
     runs-on: ${{ matrix.os }}
     steps:
       - uses: actions/checkout@v2
@@ -38,7 +38,7 @@ jobs:
           RUSTFLAGS: "-C debuginfo=0 -D warnings"
 
   ci:
-    runs-on: windows-latest
+    runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
       - uses: actions/cache@v2
@@ -55,6 +55,9 @@ jobs:
           toolchain: stable
           components: rustfmt, clippy
           override: true
+      - name: Install alsa and udev
+        run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev
+        if: runner.os == 'linux'
       - name: CI job
         # See tools/ci/src/main.rs for the commands this runs
         run: cargo run -p ci
@@ -62,49 +65,49 @@ jobs:
   check-markdown-links:
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v2
-    - name: check dead links
-      continue-on-error: true
-      id: run1
-      uses: gaurav-nelson/github-action-markdown-link-check@9710f0fec812ce0a3b98bef4c9d842fc1f39d976
-      with:
-        use-quiet-mode: 'yes'
-        use-verbose-mode: 'yes'
-        config-file: '.github/linters/markdown-link-check.json'
-    - name: Sleep for 30 seconds
-      if: steps.run1.outcome=='failure'
-      run: sleep 30s
-      shell: bash
-    - name: check dead links (retry)
-      continue-on-error: true
-      id: run2
-      if: steps.run1.outcome=='failure'
-      uses: gaurav-nelson/github-action-markdown-link-check@9710f0fec812ce0a3b98bef4c9d842fc1f39d976
-      with:
-        use-quiet-mode: 'yes'
-        use-verbose-mode: 'yes'
-        config-file: '.github/linters/markdown-link-check.json'
-    - name: Sleep for 30 seconds
-      if: steps.run2.outcome=='failure'
-      run: sleep 30s
-      shell: bash
-    - name: check dead links (retry 2)
-      continue-on-error: true
-      id: run3
-      if: steps.run2.outcome=='failure'
-      uses: gaurav-nelson/github-action-markdown-link-check@9710f0fec812ce0a3b98bef4c9d842fc1f39d976
-      with:
-        use-quiet-mode: 'yes'
-        use-verbose-mode: 'yes'
-        config-file: '.github/linters/markdown-link-check.json'
-    - name: set the status
-      if: always()
-      run: |
-        if ${{ steps.run1.outcome=='success' || steps.run2.outcome=='success' || steps.run3.outcome=='success' }}; then
-            echo success
-        else
-            exit 1
-        fi
+      - uses: actions/checkout@v2
+      - name: check dead links
+        continue-on-error: true
+        id: run1
+        uses: gaurav-nelson/github-action-markdown-link-check@9710f0fec812ce0a3b98bef4c9d842fc1f39d976
+        with:
+          use-quiet-mode: "yes"
+          use-verbose-mode: "yes"
+          config-file: ".github/linters/markdown-link-check.json"
+      - name: Sleep for 30 seconds
+        if: steps.run1.outcome=='failure'
+        run: sleep 30s
+        shell: bash
+      - name: check dead links (retry)
+        continue-on-error: true
+        id: run2
+        if: steps.run1.outcome=='failure'
+        uses: gaurav-nelson/github-action-markdown-link-check@9710f0fec812ce0a3b98bef4c9d842fc1f39d976
+        with:
+          use-quiet-mode: "yes"
+          use-verbose-mode: "yes"
+          config-file: ".github/linters/markdown-link-check.json"
+      - name: Sleep for 30 seconds
+        if: steps.run2.outcome=='failure'
+        run: sleep 30s
+        shell: bash
+      - name: check dead links (retry 2)
+        continue-on-error: true
+        id: run3
+        if: steps.run2.outcome=='failure'
+        uses: gaurav-nelson/github-action-markdown-link-check@9710f0fec812ce0a3b98bef4c9d842fc1f39d976
+        with:
+          use-quiet-mode: "yes"
+          use-verbose-mode: "yes"
+          config-file: ".github/linters/markdown-link-check.json"
+      - name: set the status
+        if: always()
+        run: |
+          if ${{ steps.run1.outcome=='success' || steps.run2.outcome=='success' || steps.run3.outcome=='success' }}; then
+              echo success
+          else
+              exit 1
+          fi
 
   markdownlint:
     runs-on: ubuntu-latest
diff --git a/Cargo.toml b/Cargo.toml
index 5e617d9..537a8ac 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
 [package]
-name = "leafwing-input-manager"
+name = "leafwing_input_manager"
 description = "A powerfully direct stateful input manager for the Bevy game engine."
-version = "0.5.2"
+version = "0.6.0"
 authors = ["Leafwing Studios"]
 homepage = "https://leafwing-studios.com/"
 repository = "https://github.com/leafwing-studios/leafwing-input-manager"
diff --git a/RELEASES.md b/RELEASES.md
index 1dda95e..81ed169 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -1,5 +1,29 @@
 # Release Notes
 
+## Version 0.6
+
+### Enhancements
+
+- You can now store `Cooldowns` on a per-action basis.
+  - This new component / resource is now included in the `InputManagerBundle`
+  - Set cooldowns for actions using `Cooldowns::set(action, cooldown)` or `Cooldowns::new`.
+  - Use `Cooldowns::ready` with `Cooldowns::trigger` as part of your action evaluation!
+  - Cooldowns advance whenever `Cooldowns::tick` is called (this will happen automatically if you add the plugin).
+- Added the `Modifier` enum, to ergonomically capture the notion of "either control/alt/shift/windows key".
+  - The corresponding `InputKind::Modifier` variant was added to match.
+  - You can conveniently construct these using the `InputKind::modified` or `InputMap::insert_modified` methods.
+
+### Usability
+
+- Implemented `Eq` for `Timing` and `InputMap`.
+- Held `ActionState` inputs will now be released when an `InputMap` is removed.
+- Improve `ToggleActions`.
+  - Make `_phantom` field public and rename into `phantom`.
+  - Add `ToggleActions::ENABLED` and `ToggleActions::DISABLED`.
+- Added `SingleAxis::negative_only` and `SingleAxis::positive_only` for triggering separate actions for each direction of an axis.
+- `ActionData::action_data` now returns a reference, rather than a clone, for consistency and explicitness
+- added `with_deadzone` methods to configure the deadzones for both `SingleAxis` and `DualAxis` inputs
+
 ## Version 0.5.2
 
 ### Bug fixes
@@ -53,7 +77,7 @@
 - `InputStreams` and `MutableInputStreams` no longer store e.g. `Option<Res<Input<MouseButton>>>`, and instead simply store `Res<Input<MouseButton>>`
   - This makes them much easier to work with and dramatically simplifies internal logic.
 - `InputStreams::from_world` no longer requires `&mut World`, as it does not require mutable access to any resources.
-- Renamed `InputMocking::send_input_to_gamepad` and `InputMocking::release_input_for_gamepad` to `InputMocking::send_input_as_gamepad` and `InputMocking::release_input_as_gamepad`.
+- Renamed `InputMocking::send_input_to_gamepad` and `InputMocking::release_input_for_gamepad` to `InputMocking::send_input_as_gamepad` and `InputMocking::send_input_as_gamepad`.
 - Added the `guess_gamepad` method to `InputStreams` and `MutableInputStreams`, which attempts to find an appropriate gamepad to use.
 - `InputMocking::pressed` and `pressed_for_gamepad` no longer require `&mut self`.
 - `UserInput::raw_inputs` now returns a `RawInputs` struct, rather than a tuple struct.
diff --git a/examples/arpg_indirection.rs b/examples/arpg_indirection.rs
index 94db648..3e8713e 100644
--- a/examples/arpg_indirection.rs
+++ b/examples/arpg_indirection.rs
@@ -112,7 +112,8 @@ fn copy_action_state(
             if let Some(&matching_ability) = ability_slot_map.get(&slot) {
                 // This copies the `ActionData` between the ActionStates,
                 // including information about how long the buttons have been pressed or released
-                ability_state.set_action_data(matching_ability, slot_state.action_data(slot));
+                ability_state
+                    .set_action_data(matching_ability, slot_state.action_data(slot).clone());
             }
         }
     }
diff --git a/examples/axis_inputs.rs b/examples/axis_inputs.rs
index 4558a32..211b4e2 100644
--- a/examples/axis_inputs.rs
+++ b/examples/axis_inputs.rs
@@ -44,6 +44,7 @@ fn spawn_player(mut commands: Commands) {
                     Action::Rudder,
                 )
                 .build(),
+            ..default()
         });
 }
 
diff --git a/examples/minimal.rs b/examples/minimal.rs
index 771a0bb..4e8ac08 100644
--- a/examples/minimal.rs
+++ b/examples/minimal.rs
@@ -33,6 +33,9 @@ fn spawn_player(mut commands: Commands) {
             action_state: ActionState::default(),
             // Describes how to convert from player inputs into those actions
             input_map: InputMap::new([(KeyCode::Space, Action::Jump)]),
+            // Any cooling-off period between repeated actions
+            // By default, there are none.
+            cooldowns: Cooldowns::default(),
         });
 }
 
diff --git a/examples/mouse_motion.rs b/examples/mouse_motion.rs
index 3d2bada..17eefe2 100644
--- a/examples/mouse_motion.rs
+++ b/examples/mouse_motion.rs
@@ -25,7 +25,7 @@ fn setup(mut commands: Commands) {
                 // Note that you can also use discrete gesture-like motion, via the `MouseMotionDirection` enum
                 .insert(DualAxis::mouse_motion(), CameraMovement::Pan)
                 .build(),
-            action_state: ActionState::default(),
+            ..default()
         });
 
     commands.spawn().insert_bundle(SpriteBundle {
diff --git a/examples/mouse_wheel.rs b/examples/mouse_wheel.rs
index c5feb6f..60438ca 100644
--- a/examples/mouse_wheel.rs
+++ b/examples/mouse_wheel.rs
@@ -35,7 +35,7 @@ fn setup(mut commands: Commands) {
                 // Or even a continous `DualAxis`!
                 // .insert(DualAxis::mouse_wheel(), Pan)
                 .build(),
-            action_state: ActionState::default(),
+            ..default()
         });
 
     commands.spawn().insert_bundle(SpriteBundle {
diff --git a/examples/press_duration.rs b/examples/press_duration.rs
index cca8c64..986c03c 100644
--- a/examples/press_duration.rs
+++ b/examples/press_duration.rs
@@ -63,7 +63,7 @@ fn spawn_player(mut commands: Commands) {
         velocity: Velocity { x: 0.0 },
         input_manager: InputManagerBundle {
             input_map: PlayerBundle::default_input_map(),
-            action_state: ActionState::default(),
+            ..default()
         },
         sprite: SpriteBundle {
             transform: Transform {
diff --git a/examples/send_actions_over_network.rs b/examples/send_actions_over_network.rs
index 19295ae..861a833 100644
--- a/examples/send_actions_over_network.rs
+++ b/examples/send_actions_over_network.rs
@@ -120,7 +120,7 @@ fn spawn_player(mut commands: Commands) {
             input_map: InputMap::new([(W, MoveLeft), (D, MoveRight), (Space, Jump)])
                 .insert(MouseButton::Left, Shoot)
                 .build(),
-            action_state: ActionState::default(),
+            ..default()
         })
         // This identifier must match on both the client and server
         // and be unique between players
diff --git a/examples/single_player.rs b/examples/single_player.rs
index db0d8d9..9351fa1 100644
--- a/examples/single_player.rs
+++ b/examples/single_player.rs
@@ -115,7 +115,7 @@ fn spawn_player(mut commands: Commands) {
         player: Player,
         input_manager: InputManagerBundle {
             input_map: PlayerBundle::default_input_map(),
-            action_state: ActionState::default(),
+            ..default()
         },
     });
 }
diff --git a/examples/virtual_dpad.rs b/examples/virtual_dpad.rs
index a17bec7..e47e3d9 100644
--- a/examples/virtual_dpad.rs
+++ b/examples/virtual_dpad.rs
@@ -28,7 +28,6 @@ fn spawn_player(mut commands: Commands) {
         .insert(Player)
         .insert_bundle(InputManagerBundle::<Action> {
             // Stores "which actions are currently activated"
-            action_state: ActionState::default(),
             // Map some arbitrary keys into a virtual direction pad that triggers our move action
             input_map: InputMap::new([(
                 VirtualDPad {
@@ -40,6 +39,7 @@ fn spawn_player(mut commands: Commands) {
                 Action::Move,
             )])
             .build(),
+            ..default()
         });
 }
 
diff --git a/src/action_state.rs b/src/action_state.rs
index a48e8fa..b227f93 100644
--- a/src/action_state.rs
+++ b/src/action_state.rs
@@ -84,7 +84,7 @@ pub struct ActionState<A: Actionlike> {
     /// The [`ActionData`] of each action
     ///
     /// The position in this vector corresponds to [`Actionlike::index`].
-    pub action_data: Vec<ActionData>,
+    action_data: Vec<ActionData>,
     _phantom: PhantomData<A>,
 }
 
@@ -160,12 +160,12 @@ impl<A: Actionlike> ActionState<A> {
         self.action_data.iter_mut().for_each(|ad| {
             // Durations should not advance while actions are consumed
             if !ad.consumed {
-                ad.timing.tick(current_instant, previous_instant)
+                ad.timing.tick(current_instant, previous_instant);
             }
         });
     }
 
-    /// Gets a copy of the [`ActionData`] of the corresponding `action`
+    /// A reference to the [`ActionData`] of the corresponding `action`
     ///
     /// Generally, it'll be clearer to call `pressed` or so on directly on the [`ActionState`].
     /// However, accessing the raw data directly allows you to examine detailed metadata holistically.
@@ -186,8 +186,34 @@ impl<A: Actionlike> ActionState<A> {
     /// ```
     #[inline]
     #[must_use]
-    pub fn action_data(&self, action: A) -> ActionData {
-        self.action_data[action.index()].clone()
+    pub fn action_data(&self, action: A) -> &ActionData {
+        &self.action_data[action.index()]
+    }
+
+    /// A mutable reference of the [`ActionData`] of the corresponding `action`
+    ///
+    /// Generally, it'll be clearer to call `pressed` or so on directly on the [`ActionState`].
+    /// However, accessing the raw data directly allows you to examine detailed metadata holistically.
+    ///
+    /// # Example
+    /// ```rust
+    /// use leafwing_input_manager::prelude::*;
+    ///
+    /// #[derive(Actionlike, Clone, Copy, PartialEq, Eq, Debug)]
+    /// enum Action {
+    ///     Run,
+    ///     Jump,
+    /// }
+    /// let mut action_state = ActionState::<Action>::default();
+    /// let mut run_data = action_state.action_data_mut(Action::Run);
+    /// run_data.axis_pair = None;
+    ///
+    /// dbg!(run_data);
+    /// ```
+    #[inline]
+    #[must_use]
+    pub fn action_data_mut(&mut self, action: A) -> &mut ActionData {
+        &mut self.action_data[action.index()]
     }
 
     /// Get the value associated with the corresponding `action`
@@ -280,7 +306,7 @@ impl<A: Actionlike> ActionState<A> {
     ///
     /// // And transfer it to the actual ability that we care about
     /// // without losing timing information
-    /// action_state.set_action_data(Action::Run, slot_1_state);
+    /// action_state.set_action_data(Action::Run, slot_1_state.clone());
     /// ```
     #[inline]
     pub fn set_action_data(&mut self, action: A, data: ActionData) {
diff --git a/src/axislike.rs b/src/axislike.rs
index 681f92b..c6d4509 100644
--- a/src/axislike.rs
+++ b/src/axislike.rs
@@ -101,6 +101,38 @@ impl SingleAxis {
             value: None,
         }
     }
+
+    /// Creates a [`SingleAxis`] with the `axis_type` and `negative_low` set to `threshold`.
+    ///
+    /// Positive values will not trigger the input.
+    pub fn negative_only(axis_type: impl Into<AxisType>, threshold: f32) -> SingleAxis {
+        SingleAxis {
+            axis_type: axis_type.into(),
+            negative_low: threshold,
+            positive_low: f32::MAX,
+            value: None,
+        }
+    }
+
+    /// Creates a [`SingleAxis`] with the `axis_type` and `positive_low` set to `threshold`.
+    ///
+    /// Negative values will not trigger the input.
+    pub fn positive_only(axis_type: impl Into<AxisType>, threshold: f32) -> SingleAxis {
+        SingleAxis {
+            axis_type: axis_type.into(),
+            negative_low: f32::MIN,
+            positive_low: threshold,
+            value: None,
+        }
+    }
+
+    /// Returns this [`SingleAxis`] with the deadzone set to the specified value
+    #[must_use]
+    pub fn with_deadzone(mut self, deadzone: f32) -> SingleAxis {
+        self.negative_low = deadzone;
+        self.positive_low = deadzone;
+        self
+    }
 }
 
 impl PartialEq for SingleAxis {
@@ -208,6 +240,14 @@ impl DualAxis {
             y: SingleAxis::mouse_motion_y(),
         }
     }
+
+    /// Returns this [`DualAxis`] with the deadzone set to the specified value
+    #[must_use]
+    pub fn with_deadzone(mut self, deadzone: f32) -> DualAxis {
+        self.x = self.x.with_deadzone(deadzone);
+        self.y = self.y.with_deadzone(deadzone);
+        self
+    }
 }
 
 #[allow(clippy::doc_markdown)] // False alarm because it thinks DPad is an un-quoted item
diff --git a/src/clashing_inputs.rs b/src/clashing_inputs.rs
index a67721c..fa47a1f 100644
--- a/src/clashing_inputs.rs
+++ b/src/clashing_inputs.rs
@@ -323,6 +323,8 @@ fn resolve_clash<A: Actionlike>(
         }
     }
 
+    println!("real clash");
+
     // There's a real clash; resolve it according to the `clash_strategy`
     match clash_strategy {
         // Do nothing
diff --git a/src/cooldown.rs b/src/cooldown.rs
new file mode 100644
index 0000000..5ab08a3
--- /dev/null
+++ b/src/cooldown.rs
@@ -0,0 +1,622 @@
+//! Cooldowns tick down until actions are ready to be used.
+
+use crate::Actionlike;
+
+use bevy::ecs::prelude::Component;
+use bevy::utils::Duration;
+use serde::{Deserialize, Serialize};
+use std::marker::PhantomData;
+
+/// The time until each action of type `A` can be used again.
+///
+/// Each action may be associated with a [`Cooldown`].
+/// If it is not, it always be treated as being ready.
+///
+/// This is typically paired with an [`ActionState`](crate::action_state::ActionState):
+/// if the action state is just-pressed (or another triggering condition is met),
+/// and the cooldown is ready, then perform the action and trigger the cooldown.
+///
+/// This type is included as part of the [`InputManagerBundle`](crate::InputManagerBundle),
+/// but can also be used as a resource for singleton game objects.
+///
+///     
+/// ```rust
+/// use leafwing_input_manager::prelude::*;
+/// use bevy::utils::Duration;
+///
+/// #[derive(Actionlike, Clone, Copy, PartialEq, Eq, Debug)]
+/// enum Action {
+///     Run,
+///     Jump,
+/// }
+///
+/// let mut action_state = ActionState::<Action>::default();
+/// let mut cooldowns = Cooldowns::new([(Cooldown::from_secs(1.), Action::Jump)]);
+///
+/// action_state.press(Action::Jump);
+///
+/// if action_state.just_pressed(Action::Jump) && cooldowns.ready(Action::Jump) {
+///    // Actually do the jumping thing here
+///    // Remember to actually begin the cooldown if you jumped!
+///    cooldowns.trigger(Action::Jump);
+/// }
+///
+/// // We just jumped, so the cooldown isn't ready yet
+/// assert!(!cooldowns.ready(Action::Jump));
+/// ```
+#[derive(Component, Debug, Clone, PartialEq, Eq)]
+pub struct Cooldowns<A: Actionlike> {
+    /// The [`Cooldown`] of each action
+    ///
+    /// The position in this vector corresponds to [`Actionlike::index`].
+    /// If [`None`], the action can always be used
+    cooldowns: Vec<Option<Cooldown>>,
+    /// A shared cooldown between all actions of type `A`.
+    ///
+    /// No action of type `A` will be ready unless this is ready.
+    /// Whenever any cooldown for an action of type `A` is triggered,
+    /// this global cooldown is triggered.
+    pub global_cooldown: Option<Cooldown>,
+    _phantom: PhantomData<A>,
+}
+
+impl<A: Actionlike> Default for Cooldowns<A> {
+    /// By default, cooldowns are not set.
+    fn default() -> Self {
+        Cooldowns {
+            cooldowns: A::variants().map(|_| None).collect(),
+            global_cooldown: None,
+            _phantom: PhantomData::default(),
+        }
+    }
+}
+
+impl<A: Actionlike> Cooldowns<A> {
+    /// Creates a new [`Cooldowns`] from an iterator of `(cooldown, action)` pairs
+    ///
+    /// If a [`Cooldown`] is not provided for an action, that action will be treated as if its cooldown is always available.
+    ///
+    /// To create an empty [`Cooldowns`] struct, use the [`Default::default`] method instead.
+    ///
+    /// # Example
+    /// ```rust
+    /// use leafwing_input_manager::cooldown::{Cooldown, Cooldowns};
+    /// use leafwing_input_manager::Actionlike;
+    /// use bevy::input::keyboard::KeyCode;
+    ///
+    /// #[derive(Actionlike, Clone, Copy, PartialEq, Eq, Hash)]
+    /// enum Action {
+    ///     Run,
+    ///     Jump,
+    ///     Shoot,
+    ///     Dash,
+    /// }
+    ///
+    /// let input_map = Cooldowns::new([
+    ///     (Cooldown::from_secs(0.1), Action::Shoot),
+    ///     (Cooldown::from_secs(1.0), Action::Dash),
+    /// ]);
+    /// ```
+    #[must_use]
+    pub fn new(cooldown_action_pairs: impl IntoIterator<Item = (Cooldown, A)>) -> Self {
+        let mut cooldowns = Cooldowns::default();
+        for (cooldown, action) in cooldown_action_pairs.into_iter() {
+            cooldowns.set(cooldown, action);
+        }
+        cooldowns
+    }
+
+    /// Triggers the cooldown of the `action` if it is available to be used.
+    ///
+    /// This should always be paired with [`Cooldowns::ready`],
+    /// to check if the action can be used before triggering its cooldown.
+    #[inline]
+    pub fn trigger(&mut self, action: A) {
+        if let Some(cooldown) = self.cooldown_mut(action) {
+            cooldown.trigger();
+        }
+
+        if let Some(global_cooldown) = self.global_cooldown.as_mut() {
+            global_cooldown.trigger();
+        }
+    }
+
+    /// Can the corresponding `action` be used?
+    ///
+    /// This will be `true` if the underlying [`Cooldown::ready`] call is true,
+    /// or if no cooldown is stored for this action.
+    #[inline]
+    #[must_use]
+    pub fn ready(&self, action: A) -> bool {
+        if !self.gcd_ready() {
+            return false;
+        }
+
+        if let Some(cooldown) = self.cooldown(action) {
+            cooldown.ready()
+        } else {
+            true
+        }
+    }
+
+    /// Has the global cooldown for actions of type `A` expired?
+    ///
+    /// Returns `true` if no GCD is set.
+    #[inline]
+    #[must_use]
+    pub fn gcd_ready(&self) -> bool {
+        if let Some(global_cooldown) = self.global_cooldown.as_ref() {
+            global_cooldown.ready()
+        } else {
+            true
+        }
+    }
+
+    /// Advances each underlying [`Cooldown`] according to the elapsed `delta_time`.
+    pub fn tick(&mut self, delta_time: Duration) {
+        self.iter_mut().for_each(|cd| cd.tick(delta_time));
+
+        if let Some(global_cooldown) = self.global_cooldown.as_mut() {
+            global_cooldown.tick(delta_time);
+        }
+    }
+
+    /// Returns an iterator of references to the underlying non-[`None`] [`Cooldown`]s
+    #[inline]
+    pub fn iter(&self) -> impl Iterator<Item = &Cooldown> {
+        self.cooldowns.iter().flatten()
+    }
+
+    /// Returns an iterator of mutable references to the underlying non-[`None`] [`Cooldown`]s
+    #[inline]
+    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Cooldown> {
+        self.cooldowns.iter_mut().flatten()
+    }
+
+    /// The cooldown associated with the specified `action`, if any.
+    #[inline]
+    #[must_use]
+    pub fn cooldown(&self, action: A) -> &Option<Cooldown> {
+        &self.cooldowns[action.index()]
+    }
+
+    /// A mutable reference to the cooldown associated with the specified `action`, if any.
+    #[inline]
+    #[must_use]
+    pub fn cooldown_mut(&mut self, action: A) -> &mut Option<Cooldown> {
+        &mut self.cooldowns[action.index()]
+    }
+
+    /// Set a cooldown for the specified `action`.
+    ///
+    /// If a cooldown already existed, it will be replaced by a new cooldown with the specified duration.
+    #[inline]
+    pub fn set(&mut self, cooldown: Cooldown, action: A) {
+        *self.cooldown_mut(action) = Some(cooldown);
+    }
+
+    /// Remove any cooldown for the specified `action`.
+    #[inline]
+    pub fn remove(&mut self, action: A) {
+        *self.cooldown_mut(action) = None;
+    }
+}
+
+/// A timer-like struct that records the amount of time until an action is available to be used again.
+///
+/// Cooldowns are typically stored in an [`ActionState`](crate::action_state::ActionState), associated with an action that is to be
+/// cooldown-regulated.
+///
+/// When initialized, cooldowns are always fully available.
+///
+/// ```rust
+/// use bevy::utils::Duration;
+/// use leafwing_input_manager::cooldown::Cooldown;
+///
+/// let mut cooldown = Cooldown::new(Duration::from_secs(3));
+/// assert_eq!(cooldown.remaining(), Duration::ZERO);
+///
+/// cooldown.trigger();
+/// assert_eq!(cooldown.remaining(), Duration::from_secs(3));
+///
+/// cooldown.tick(Duration::from_secs(1));
+/// assert!(!cooldown.ready());
+///
+/// cooldown.tick(Duration::from_secs(5));
+/// let triggered = cooldown.trigger();
+/// assert!(triggered);
+///
+/// cooldown.refresh();
+/// assert!(cooldown.ready());
+/// ```
+///
+/// ## Charges
+///
+/// Cooldowns can have more than one **charge**.
+/// When a cooldown is triggered, one charge will be consumed.
+/// When the cooldown expires, one charge will be added.
+/// Cooldowns are only ready if at least one charge is available.
+///
+/// ```rust
+/// use leafwing_input_manager::cooldown::Cooldown;
+/// use bevy::utils::Duration;
+///
+/// let mut cooldown = Cooldown::from_secs(1.).with_charges(3);
+/// assert_eq!(cooldown.charges(), 3);
+/// assert_eq!(cooldown.max_charges(), 3);
+/// assert!(cooldown.ready());
+///
+/// cooldown.trigger();
+/// assert_eq!(cooldown.charges(), 2);
+///
+/// cooldown.trigger();
+/// cooldown.trigger();
+/// assert_eq!(cooldown.charges(), 0);
+/// assert!(!cooldown.ready());
+///
+/// cooldown.tick(Duration::from_secs(1));
+/// assert_eq!(cooldown.charges(), 1);
+/// assert!(cooldown.ready());
+///
+/// cooldown.tick(Duration::from_secs(9001));
+/// assert_eq!(cooldown.charges(), cooldown.max_charges());
+/// ```
+#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)]
+pub struct Cooldown {
+    max_time: Duration,
+    elapsed_time: Duration,
+    charges: u8,
+    max_charges: u8,
+}
+
+impl Cooldown {
+    /// Creates a new [`Cooldown`], which will take `max_time` after it is used until it is ready again.
+    ///
+    /// # Panics
+    ///
+    /// The provided max time cannot be [`Duration::ZERO`].
+    /// Instead, use [`None`] in the [`Cooldowns`] struct for an action without a cooldown.
+    pub fn new(max_time: Duration) -> Cooldown {
+        assert!(max_time != Duration::ZERO);
+
+        Cooldown {
+            max_time,
+            elapsed_time: Duration::ZERO,
+            charges: 1,
+            max_charges: 1,
+        }
+    }
+
+    /// Creates a new [`Cooldown`] with a [`f32`] number of seconds, which will take `max_time` after it is used until it is ready again.
+    ///
+    /// # Panics
+    ///
+    /// The provided max time must be greater than 0.
+    /// Instead, use [`None`] in the [`Cooldowns`] struct for an action without a cooldown.
+    pub fn from_secs(max_time: f32) -> Cooldown {
+        assert!(max_time > 0.);
+        let max_time = Duration::from_secs_f32(max_time);
+
+        Cooldown::new(max_time)
+    }
+
+    /// Advance the cooldown by `delta_time`.
+    ///
+    /// If the elapsed time is enough to reset the cooldown, the number of available charges.
+    pub fn tick(&mut self, delta_time: Duration) {
+        assert!(self.max_time != Duration::ZERO);
+
+        // Ticking a cooldown at max charges has no effect
+        if self.charges == self.max_charges {
+            // Elapsed time is always zero when we're at max charges
+            self.elapsed_time = Duration::ZERO;
+            return;
+        }
+
+        let total_time = self.elapsed_time.saturating_add(delta_time);
+
+        let total_nanos: u64 = total_time.as_nanos().try_into().unwrap_or(u64::MAX);
+        let max_nanos: u64 = self.max_time.as_nanos().try_into().unwrap_or(u64::MAX);
+
+        let n_completed = (total_nanos / max_nanos).try_into().unwrap_or(u8::MAX);
+        let extra_time = Duration::from_nanos(total_nanos % max_nanos);
+
+        let excess_completions = self.add_charges(n_completed);
+        if excess_completions == 0 {
+            self.elapsed_time = (self.elapsed_time.saturating_add(extra_time)).min(self.max_time);
+        } else {
+            self.elapsed_time = self.max_time;
+        }
+    }
+
+    /// Is this action ready to be used?
+    ///
+    /// This will be true if and only if at least one charge is available.
+    /// For cooldowns without charges, this will be true if `time_remaining` is [`Duration::Zero`].
+    pub fn ready(&self) -> bool {
+        self.charges > 0
+    }
+
+    /// Refreshes the cooldown, causing the underlying action to be ready to use immediately.
+    ///
+    /// If this cooldown has charges, the number of available charges is increased by one (but the point within the cycle is unchanged).
+    #[inline]
+    pub fn refresh(&mut self) {
+        self.add_charges(1);
+    }
+
+    /// Use the underlying cooldown if and only if it is ready, resetting the cooldown to its maximum value.
+    ///
+    /// If this cooldown has multiple charges, only one will be consumed.
+    ///
+    /// Returns a boolean indicating whether the cooldown was ready.
+    /// If the cooldown was not ready, has no effect.
+    #[inline]
+    pub fn trigger(&mut self) -> bool {
+        if self.ready() {
+            self.charges = self.charges.saturating_sub(1);
+            true
+        } else {
+            false
+        }
+    }
+
+    /// Returns the time that it will take for this action to be ready to use again after being triggered.
+    #[inline]
+    pub fn max_time(&self) -> Duration {
+        self.max_time
+    }
+
+    /// Sets the time that it will take for this action to be ready to use again after being triggered.
+    ///
+    /// If the current time remaining is greater than the new max time, it will be clamped to the `max_time`.
+    ///
+    /// # Panics
+    ///
+    /// The provided max time cannot be [`Duration::ZERO`].
+    /// Instead, use [`None`] in the [`Cooldowns`] struct for an action without a cooldown.
+    #[inline]
+    pub fn set_max_time(&mut self, max_time: Duration) {
+        assert!(max_time != Duration::ZERO);
+
+        self.max_time = max_time;
+        self.elapsed_time = self.elapsed_time.min(max_time);
+    }
+
+    /// Returns the time that has passed since the cooldown was triggered.
+    #[inline]
+    pub fn elapsed(&self) -> Duration {
+        self.elapsed_time
+    }
+
+    /// Sets the time that has passed since the cooldown was triggered.
+    ///
+    /// This will always be clamped between [`Duration::ZERO`] and the `max_time` of this cooldown.
+    #[inline]
+    pub fn set_elapsed(&mut self, elapsed_time: Duration) {
+        self.elapsed_time = elapsed_time.clamp(Duration::ZERO, self.max_time);
+    }
+
+    /// Returns the time remaining until the next charge is ready.
+    ///
+    /// When a cooldown is fully charged, this will return [`Duration::ZERO`].
+    #[inline]
+    pub fn remaining(&self) -> Duration {
+        if self.elapsed_time == Duration::ZERO && self.charges == self.max_charges {
+            // Users expect cooldowns that are ready to have zero time remaining,
+            // so we wrap over the math to account for this
+            Duration::ZERO
+        } else {
+            self.max_time.saturating_sub(self.elapsed_time)
+        }
+    }
+
+    /// Sets the time remaining until the next charge is ready.
+    ///
+    /// This will always be clamped between [`Duration::ZERO`] and the `max_time` of this cooldown.
+    #[inline]
+    pub fn set_remaining(&mut self, time_remaining: Duration) {
+        self.elapsed_time = self
+            .max_time
+            .saturating_sub(time_remaining.clamp(Duration::ZERO, self.max_time));
+    }
+}
+
+// Charge-related methods
+impl Cooldown {
+    /// Transforms the [`Cooldown`] to set the `max_charges` to the provided value.
+    ///
+    /// All charges will be available when first created.
+    #[inline]
+    pub fn with_charges(self, max_charges: u8) -> Cooldown {
+        Cooldown {
+            charges: max_charges,
+            max_charges,
+            ..self
+        }
+    }
+
+    /// The current number of available charges
+    #[inline]
+    pub fn charges(&self) -> u8 {
+        self.charges
+    }
+
+    /// The maximum number of available charges
+    #[inline]
+    pub fn max_charges(&self) -> u8 {
+        self.max_charges
+    }
+
+    /// Adds `charges` to the current number of available charges
+    ///
+    /// This will never exceed the maximum number of charges.
+    /// Returns the number of excess charges.
+    #[inline]
+    pub fn add_charges(&mut self, charges: u8) -> u8 {
+        let new_total = self.charges.saturating_add(charges);
+
+        let excess = new_total.saturating_sub(self.max_charges);
+        self.charges = new_total.min(self.max_charges);
+        excess
+    }
+
+    /// Set the current number of available charges
+    ///
+    /// This will never exceed the maximum number of charges.
+    /// Returns the number of excess charges.
+    #[inline]
+    pub fn set_charges(&mut self, charges: u8) -> u8 {
+        let excess = charges.saturating_sub(self.max_charges);
+        self.charges = charges.min(self.max_charges);
+        excess
+    }
+
+    /// Set the maximmum number of available charges
+    ///
+    /// If the number of charges available is greater than this number, it will be reduced to the new cap.
+    #[inline]
+    pub fn set_max_charges(&mut self, max_charges: u8) {
+        self.max_charges = max_charges;
+        self.charges = self.charges.min(self.max_charges);
+    }
+}
+
+#[cfg(test)]
+mod tick_tests {
+    use super::*;
+
+    #[test]
+    #[should_panic]
+    fn zero_duration_cooldown_cannot_be_constructed() {
+        Cooldown::new(Duration::ZERO);
+    }
+
+    #[test]
+    fn tick_has_no_effect_on_fresh_cooldown() {
+        let cooldown = Cooldown::from_secs(1.);
+        let mut cloned_cooldown = cooldown.clone();
+        cloned_cooldown.tick(Duration::from_secs_f32(1.234));
+        assert_eq!(cooldown, cloned_cooldown);
+    }
+
+    #[test]
+    fn cooldowns_are_ready_when_refreshed() {
+        let mut cooldown = Cooldown::from_secs(1.);
+        assert!(cooldown.ready());
+        cooldown.trigger();
+        assert!(!cooldown.ready());
+        cooldown.refresh();
+        assert!(cooldown.ready());
+    }
+
+    #[test]
+    fn ticking_changes_cooldown() {
+        let cooldown = Cooldown::new(Duration::from_millis(1000));
+        let mut cloned_cooldown = cooldown.clone();
+        cloned_cooldown.trigger();
+        assert!(cooldown != cloned_cooldown);
+
+        cloned_cooldown.tick(Duration::from_millis(123));
+        assert!(cooldown != cloned_cooldown);
+    }
+
+    #[test]
+    fn cooldowns_reset_after_being_ticked() {
+        let mut cooldown = Cooldown::from_secs(1.);
+        cooldown.trigger();
+        assert!(!cooldown.ready());
+
+        cooldown.tick(Duration::from_secs(3));
+        assert!(cooldown.ready());
+    }
+
+    #[test]
+    fn time_remaining_on_fresh_cooldown_is_zero() {
+        let cooldown = Cooldown::from_secs(1.);
+        assert_eq!(cooldown.remaining(), Duration::ZERO);
+    }
+
+    #[test]
+    fn time_elapsed_on_fresh_cooldown_is_zero() {
+        let cooldown = Cooldown::from_secs(1.);
+        assert_eq!(cooldown.elapsed(), Duration::ZERO);
+    }
+}
+
+#[cfg(test)]
+mod charge_tests {
+    use super::*;
+
+    #[test]
+    fn cooldowns_start_with_one_charge() {
+        let cooldown = Cooldown::from_secs(1.);
+        assert_eq!(cooldown.charges(), 1);
+        assert_eq!(cooldown.max_charges(), 1);
+    }
+
+    #[test]
+    fn cooldowns_have_no_charges_after_being_triggered() {
+        let mut cooldown = Cooldown::from_secs(1.);
+        cooldown.trigger();
+        assert_eq!(cooldown.charges(), 0);
+        assert!(!cooldown.ready())
+    }
+
+    #[test]
+    fn cooldowns_gain_a_charge_when_refreshed() {
+        let mut cooldown = Cooldown::from_secs(1.);
+        cooldown.trigger();
+        cooldown.refresh();
+        assert_eq!(cooldown.charges(), 1);
+    }
+
+    #[test]
+    fn charges_are_depleted() {
+        let mut cooldown = Cooldown::from_secs(1.).with_charges(3);
+        assert_eq!(cooldown.charges(), 3);
+        cooldown.trigger();
+        assert_eq!(cooldown.charges(), 2);
+        cooldown.trigger();
+        assert_eq!(cooldown.charges(), 1);
+        cooldown.trigger();
+        assert_eq!(cooldown.charges(), 0);
+        cooldown.trigger();
+        assert_eq!(cooldown.charges(), 0);
+    }
+
+    #[test]
+    fn cooldown_time_is_preserved_when_charge_depleted() {
+        let mut cooldown = Cooldown::new(Duration::from_millis(1000)).with_charges(3);
+        cooldown.trigger();
+        cooldown.tick(Duration::from_millis(300));
+        assert_eq!(cooldown.remaining(), Duration::from_millis(700));
+        cooldown.trigger();
+        assert_eq!(cooldown.remaining(), Duration::from_millis(700));
+    }
+
+    #[test]
+    fn charges_regenerate_one_at_a_time() {
+        let mut cooldown = Cooldown::new(Duration::from_secs(1)).with_charges(3);
+        cooldown.set_charges(0);
+        assert_eq!(cooldown.charges(), 0);
+
+        cooldown.tick(Duration::from_secs(1));
+        assert_eq!(cooldown.charges(), 1);
+        cooldown.tick(Duration::from_secs(1));
+        assert_eq!(cooldown.charges(), 2);
+        cooldown.tick(Duration::from_secs(1));
+        assert_eq!(cooldown.charges(), 3);
+        cooldown.tick(Duration::from_secs(1));
+        assert_eq!(cooldown.charges(), 3);
+    }
+
+    #[test]
+    fn multiple_charges_regenerate_in_long_tick() {
+        let mut cooldown = Cooldown::from_secs(1.).with_charges(3);
+        cooldown.set_charges(0);
+
+        cooldown.tick(Duration::from_secs(2));
+        assert_eq!(cooldown.charges(), 2);
+    }
+}
diff --git a/src/display_impl.rs b/src/display_impl.rs
index b0e70f5..b5d7eb4 100644
--- a/src/display_impl.rs
+++ b/src/display_impl.rs
@@ -44,6 +44,7 @@ impl Display for InputKind {
             InputKind::MouseWheel(button) => write!(f, "{button:?}"),
             InputKind::MouseMotion(button) => write!(f, "{button:?}"),
             InputKind::Keyboard(button) => write!(f, "{button:?}"),
+            InputKind::Modifier(button) => write!(f, "{button:?}"),
         }
     }
 }
diff --git a/src/input_map.rs b/src/input_map.rs
index c10f7e2..87f15de 100644
--- a/src/input_map.rs
+++ b/src/input_map.rs
@@ -4,7 +4,7 @@ use crate::action_state::ActionData;
 use crate::buttonlike::ButtonState;
 use crate::clashing_inputs::ClashStrategy;
 use crate::input_streams::InputStreams;
-use crate::user_input::{InputKind, UserInput};
+use crate::user_input::{InputKind, Modifier, UserInput};
 use crate::Actionlike;
 
 use bevy::ecs::component::Component;
@@ -60,15 +60,11 @@ use std::marker::PhantomData;
 /// input_map.insert(MouseButton::Left, Action::Run)
 /// .insert(KeyCode::LShift, Action::Run)
 /// // Chords
-/// .insert_chord([KeyCode::LControl, KeyCode::R], Action::Run)
+/// .insert_modified(Modifier::Control, KeyCode::R, Action::Run)
 /// .insert_chord([InputKind::Keyboard(KeyCode::H),
 ///                InputKind::GamepadButton(GamepadButtonType::South),
 ///                InputKind::Mouse(MouseButton::Middle)],
-///            Action::Run)
-/// .insert_chord([InputKind::Keyboard(KeyCode::H),
-///                InputKind::GamepadButton(GamepadButtonType::South),
-///                InputKind::Mouse(MouseButton::Middle)],
-///            Action::Hide);
+///            Action::Run);
 ///
 /// // Removal
 /// input_map.clear_action(Action::Hide);
@@ -214,6 +210,8 @@ impl<A: Actionlike> InputMap<A> {
     /// Any iterator that can be converted into a [`Button`] can be supplied, but will be converted into a [`PetitSet`] for storage and use.
     /// Chords can also be added with the [insert](Self::insert) method, if the [`UserInput::Chord`] variant is constructed explicitly.
     ///
+    /// When working with keyboard modifier keys, consider using the `insert_modified` method instead.
+    ///
     /// # Panics
     ///
     /// Panics if the map is full and `buttons` is not a duplicate.
@@ -226,6 +224,19 @@ impl<A: Actionlike> InputMap<A> {
         self
     }
 
+    /// Inserts a mapping between the simultaneous combination of the [`Modifier`] plus the `input` and the `action` provided.
+    ///
+    /// When working with keyboard modifiers, should be preferred over `insert_chord`.
+    pub fn insert_modified(
+        &mut self,
+        modifier: Modifier,
+        input: impl Into<InputKind>,
+        action: A,
+    ) -> &mut Self {
+        self.insert(UserInput::modified(modifier, input), action);
+        self
+    }
+
     /// Merges the provided [`InputMap`] into the [`InputMap`] this method was called on
     ///
     /// This adds both of their bindings to the resulting [`InputMap`].
diff --git a/src/input_streams.rs b/src/input_streams.rs
index 379c011..0b713d2 100644
--- a/src/input_streams.rs
+++ b/src/input_streams.rs
@@ -140,6 +140,11 @@ impl<'a> InputStreams<'a> {
                 }
             }
             InputKind::Keyboard(keycode) => self.keycode.pressed(keycode),
+            InputKind::Modifier(modifier) => {
+                let key_codes = modifier.key_codes();
+                // Short circuiting is probably not worth the branch here
+                self.keycode.pressed(key_codes[0]) | self.keycode.pressed(key_codes[1])
+            }
             InputKind::Mouse(mouse_button) => self.mouse_button.pressed(mouse_button),
             InputKind::MouseWheel(mouse_wheel_direction) => {
                 let mut total_mouse_wheel_movement = 0.0;
@@ -312,9 +317,9 @@ impl<'a> InputStreams<'a> {
 
     /// Get the axis pair associated to the user input.
     ///
-    /// If `input` is not a [`DualAxis`] or [`VirtualDPad`], returns [`None`].
+    /// If `input` is not a [`DualAxis`](crate::axislike::DualAxis) or [`VirtualDPad`], returns [`None`].
     ///
-    /// See [`ActionState::action_axis_pair()`] for usage.
+    /// See [`ActionState::action_axis_pair()`](crate::action_state::ActionState) for usage.
     ///
     /// # Warning
     ///
@@ -482,3 +487,39 @@ impl<'a> From<&'a MutableInputStreams<'a>> for InputStreams<'a> {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::MutableInputStreams;
+    use crate::prelude::MockInput;
+    use bevy::input::InputPlugin;
+    use bevy::prelude::*;
+
+    #[test]
+    fn modifier_key_triggered_by_either_input() {
+        use crate::user_input::Modifier;
+        let mut app = App::new();
+        app.add_plugin(InputPlugin);
+
+        let mut input_streams = MutableInputStreams::from_world(&mut app.world, None);
+        assert!(!input_streams.pressed(Modifier::Control));
+
+        input_streams.send_input(KeyCode::LControl);
+        app.update();
+
+        let mut input_streams = MutableInputStreams::from_world(&mut app.world, None);
+        assert!(input_streams.pressed(Modifier::Control));
+
+        input_streams.reset_inputs();
+        app.update();
+
+        let mut input_streams = MutableInputStreams::from_world(&mut app.world, None);
+        assert!(!input_streams.pressed(Modifier::Control));
+
+        input_streams.send_input(KeyCode::RControl);
+        app.update();
+
+        let input_streams = MutableInputStreams::from_world(&mut app.world, None);
+        assert!(input_streams.pressed(Modifier::Control));
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index bcc1521..b498cf4 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,6 +4,7 @@
 #![doc = include_str!("../README.md")]
 
 use crate::action_state::ActionState;
+use crate::cooldown::Cooldowns;
 use crate::input_map::InputMap;
 use bevy::ecs::prelude::*;
 use std::marker::PhantomData;
@@ -12,6 +13,7 @@ pub mod action_state;
 pub mod axislike;
 pub mod buttonlike;
 pub mod clashing_inputs;
+pub mod cooldown;
 mod display_impl;
 pub mod errors;
 pub mod input_map;
@@ -31,9 +33,10 @@ pub mod prelude {
     pub use crate::axislike::{DualAxis, MouseWheelAxisType, SingleAxis, VirtualDPad};
     pub use crate::buttonlike::MouseWheelDirection;
     pub use crate::clashing_inputs::ClashStrategy;
+    pub use crate::cooldown::{Cooldown, Cooldowns};
     pub use crate::input_map::InputMap;
     pub use crate::input_mocking::MockInput;
-    pub use crate::user_input::UserInput;
+    pub use crate::user_input::{Modifier, UserInput};
 
     pub use crate::plugin::InputManagerPlugin;
     pub use crate::plugin::ToggleActions;
@@ -132,10 +135,12 @@ impl<A: Actionlike> Default for ActionIter<A> {
 /// Use with [`InputManagerPlugin`](crate::plugin::InputManagerPlugin), providing the same enum type to both.
 #[derive(Bundle)]
 pub struct InputManagerBundle<A: Actionlike> {
-    /// An [ActionState] component
+    /// An [`ActionState`] component
     pub action_state: ActionState<A>,
-    /// An [InputMap] component
+    /// An [`InputMap`] component
     pub input_map: InputMap<A>,
+    /// A [`Cooldowns`] component
+    pub cooldowns: Cooldowns<A>,
 }
 
 // Cannot use derive(Default), as it forces an undesirable bound on our generics
@@ -144,6 +149,7 @@ impl<A: Actionlike> Default for InputManagerBundle<A> {
         Self {
             action_state: ActionState::default(),
             input_map: InputMap::default(),
+            cooldowns: Cooldowns::default(),
         }
     }
 }
diff --git a/src/plugin.rs b/src/plugin.rs
index f95b5c4..e6372b5 100644
--- a/src/plugin.rs
+++ b/src/plugin.rs
@@ -93,6 +93,13 @@ impl<A: Actionlike> Plugin for InputManagerPlugin<A> {
                         .label(InputManagerSystem::Tick)
                         .before(InputManagerSystem::Update),
                 )
+                .add_system_to_stage(
+                    CoreStage::PreUpdate,
+                    tick_cooldowns::<A>
+                        .with_run_criteria(run_if_enabled::<A>)
+                        .label(InputManagerSystem::Tick)
+                        .before(InputManagerSystem::Update),
+                )
                 .add_system_to_stage(
                     CoreStage::PreUpdate,
                     update_action_state::<A>
@@ -105,7 +112,8 @@ impl<A: Actionlike> Plugin for InputManagerPlugin<A> {
                     release_on_disable::<A>
                         .label(InputManagerSystem::ReleaseOnDisable)
                         .after(InputManagerSystem::Update),
-                );
+                )
+                .add_system_to_stage(CoreStage::PostUpdate, release_on_input_map_removed::<A>);
 
                 #[cfg(feature = "ui")]
                 app.add_system_to_stage(
@@ -147,7 +155,21 @@ pub struct ToggleActions<A: Actionlike> {
     ///
     /// When this is set to false, all corresponding [`ActionState`]s are released
     pub enabled: bool,
-    _phantom: PhantomData<A>,
+    /// Marker that stores the type of action to toggle
+    pub phantom: PhantomData<A>,
+}
+
+impl<A: Actionlike> ToggleActions<A> {
+    /// A [`ToggleActions`] in enabled state.
+    pub const ENABLED: ToggleActions<A> = ToggleActions::<A> {
+        enabled: true,
+        phantom: PhantomData::<A>,
+    };
+    /// A [`ToggleActions`] in disabled state.
+    pub const DISABLED: ToggleActions<A> = ToggleActions::<A> {
+        enabled: false,
+        phantom: PhantomData::<A>,
+    };
 }
 
 // Implement manually to not require [`Default`] for `A`
@@ -155,7 +177,7 @@ impl<A: Actionlike> Default for ToggleActions<A> {
     fn default() -> Self {
         Self {
             enabled: true,
-            _phantom: PhantomData::<A>,
+            phantom: PhantomData::<A>,
         }
     }
 }
@@ -165,7 +187,9 @@ impl<A: Actionlike> Default for ToggleActions<A> {
 /// `Reset` must occur before `Update`
 #[derive(SystemLabel, Clone, Hash, Debug, PartialEq, Eq)]
 pub enum InputManagerSystem {
-    /// Advances actions timers to clean up the state of the input manager and clear `just_pressed` and just_released`
+    /// Advances action timers and cooldowns.
+    ///
+    /// Cleans up the state of the input manager, clearing `just_pressed` and just_released`
     Tick,
     /// Collects input data to update the [`ActionState`](crate::action_state::ActionState)
     Update,
diff --git a/src/systems.rs b/src/systems.rs
index 430b17f..793ca6b 100644
--- a/src/systems.rs
+++ b/src/systems.rs
@@ -5,6 +5,7 @@ use crate::action_state::ActionStateDriver;
 use crate::{
     action_state::{ActionDiff, ActionState},
     clashing_inputs::ClashStrategy,
+    cooldown::Cooldowns,
     input_map::InputMap,
     input_streams::InputStreams,
     plugin::ToggleActions,
@@ -54,6 +55,25 @@ pub fn tick_action_state<A: Actionlike>(
     *stored_previous_instant = time.last_update();
 }
 
+/// Advances all [`Cooldowns`].
+pub fn tick_cooldowns<A: Actionlike>(
+    mut query: Query<&mut Cooldowns<A>>,
+    cooldowns: Option<ResMut<Cooldowns<A>>>,
+    time: Res<Time>,
+) {
+    let delta_time = time.delta();
+
+    // Only tick the Cooldowns resource if it exists
+    if let Some(mut cooldowns) = cooldowns {
+        cooldowns.tick(delta_time);
+    }
+
+    // Only tick the Cooldowns components if they exist
+    for mut cooldowns in query.iter_mut() {
+        cooldowns.tick(delta_time);
+    }
+}
+
 /// Fetches all of the releveant [`Input`] resources to update [`ActionState`] according to the [`InputMap`]
 ///
 /// Missing resources will be ignored, and treated as if none of the corresponding inputs were pressed
@@ -212,6 +232,39 @@ pub fn release_on_disable<A: Actionlike>(
     }
 }
 
+/// Release all inputs when an [`InputMap<A>`] is removed to prevent them from being held forever.
+///
+/// By default, [`InputManagerPlugin<A>`] will run this on [`CoreStage::PostUpdate`](bevy::prelude::CoreStage::PostUpdate).
+/// For components you must remove the [`InputMap<A>`] before [`CoreStage::PostUpdate`](bevy::prelude::CoreStage::PostUpdate)
+/// or this will not run.
+pub fn release_on_input_map_removed<A: Actionlike>(
+    removed_components: RemovedComponents<InputMap<A>>,
+    input_map_resource: Option<ResMut<InputMap<A>>>,
+    action_state_resource: Option<ResMut<ActionState<A>>>,
+    mut input_map_resource_existed: Local<bool>,
+    mut action_state_query: Query<&mut ActionState<A>>,
+) {
+    let mut iter = action_state_query.iter_many_mut(removed_components.iter());
+    while let Some(mut action_state) = iter.fetch_next() {
+        action_state.release_all();
+    }
+
+    // Detect when an InputMap resource is removed.
+    if input_map_resource.is_some() {
+        // Store if the resource existed so we know if it was removed later.
+        *input_map_resource_existed = true;
+    } else if *input_map_resource_existed {
+        // The input map does not exist and our local is true so we know the input map was removed.
+
+        if let Some(mut action_state) = action_state_resource {
+            action_state.release_all();
+        }
+
+        // Reset our local so our removal detection is only triggered once.
+        *input_map_resource_existed = false;
+    }
+}
+
 /// Returns [`ShouldRun::No`] if [`DisableInput`] exists and [`ShouldRun::Yes`] otherwise
 pub(super) fn run_if_enabled<A: Actionlike>(toggle_actions: Res<ToggleActions<A>>) -> ShouldRun {
     if toggle_actions.enabled {
diff --git a/src/user_input.rs b/src/user_input.rs
index 61ccbec..31253a4 100644
--- a/src/user_input.rs
+++ b/src/user_input.rs
@@ -28,7 +28,20 @@ pub enum UserInput {
 }
 
 impl UserInput {
-    /// Creates a [`UserInput::Chord`] from an iterator of [`InputKind`]s
+    /// Creates a [`UserInput::Chord`] from a [`Modifier`] and an `input` that can be converted into an [`InputKind`]
+    ///
+    /// When working with keyboard modifiers, should be preferred over manually specifying both the left and right variant.
+    pub fn modified(modifier: Modifier, input: impl Into<InputKind>) -> UserInput {
+        let modifier: InputKind = modifier.into();
+        let input: InputKind = input.into();
+        let mut set: PetitSet<InputKind, 8> = PetitSet::default();
+        set.insert(modifier);
+        set.insert(input);
+
+        UserInput::Chord(set)
+    }
+
+    /// Creates a [`UserInput::Chord`] from an iterator of inputs of the same type that can be converted into an [`InputKind`]s
     ///
     /// If `inputs` has a length of 1, a [`UserInput::Single`] variant will be returned instead.
     pub fn chord(inputs: impl IntoIterator<Item = impl Into<InputKind>>) -> Self {
@@ -84,13 +97,7 @@ impl UserInput {
     /// ```
     pub fn n_matching(&self, buttons: &HashSet<InputKind>) -> usize {
         match self {
-            UserInput::Single(button) => {
-                if buttons.contains(button) {
-                    1
-                } else {
-                    0
-                }
-            }
+            UserInput::Single(button) => usize::from(buttons.contains(button)),
             UserInput::Chord(chord_buttons) => {
                 let mut n_matching = 0;
                 for button in buttons.iter() {
@@ -140,6 +147,11 @@ impl UserInput {
                     .push((single_axis.axis_type, single_axis.value)),
                 InputKind::GamepadButton(button) => raw_inputs.gamepad_buttons.push(button),
                 InputKind::Keyboard(button) => raw_inputs.keycodes.push(button),
+                InputKind::Modifier(modifier) => {
+                    let key_codes = modifier.key_codes();
+                    raw_inputs.keycodes.push(key_codes[0]);
+                    raw_inputs.keycodes.push(key_codes[1]);
+                }
                 InputKind::Mouse(button) => raw_inputs.mouse_buttons.push(button),
                 InputKind::MouseWheel(button) => raw_inputs.mouse_wheel.push(button),
                 InputKind::MouseMotion(button) => raw_inputs.mouse_motion.push(button),
@@ -160,6 +172,11 @@ impl UserInput {
                             .push((single_axis.axis_type, single_axis.value)),
                         InputKind::GamepadButton(button) => raw_inputs.gamepad_buttons.push(button),
                         InputKind::Keyboard(button) => raw_inputs.keycodes.push(button),
+                        InputKind::Modifier(modifier) => {
+                            let key_codes = modifier.key_codes();
+                            raw_inputs.keycodes.push(key_codes[0]);
+                            raw_inputs.keycodes.push(key_codes[1]);
+                        }
                         InputKind::Mouse(button) => raw_inputs.mouse_buttons.push(button),
                         InputKind::MouseWheel(button) => raw_inputs.mouse_wheel.push(button),
                         InputKind::MouseMotion(button) => raw_inputs.mouse_motion.push(button),
@@ -187,6 +204,11 @@ impl UserInput {
                             .push((single_axis.axis_type, single_axis.value)),
                         InputKind::GamepadButton(button) => raw_inputs.gamepad_buttons.push(button),
                         InputKind::Keyboard(button) => raw_inputs.keycodes.push(button),
+                        InputKind::Modifier(modifier) => {
+                            let key_codes = modifier.key_codes();
+                            raw_inputs.keycodes.push(key_codes[0]);
+                            raw_inputs.keycodes.push(key_codes[1]);
+                        }
                         InputKind::Mouse(button) => raw_inputs.mouse_buttons.push(button),
                         InputKind::MouseWheel(button) => raw_inputs.mouse_wheel.push(button),
                         InputKind::MouseMotion(button) => raw_inputs.mouse_motion.push(button),
@@ -253,6 +275,12 @@ impl From<MouseMotionDirection> for UserInput {
     }
 }
 
+impl From<Modifier> for UserInput {
+    fn from(input: Modifier) -> Self {
+        UserInput::Single(InputKind::Modifier(input))
+    }
+}
+
 /// The different kinds of supported input bindings.
 ///
 /// See [`InputMode`] for the value-less equivalent. Commonly stored in the [`UserInput`] enum.
@@ -272,6 +300,8 @@ pub enum InputKind {
     DualAxis(DualAxis),
     /// A button on a keyboard
     Keyboard(KeyCode),
+    /// A keyboard modifier, like `Ctrl` or `Alt`, which doesn't care about which side it's on.
+    Modifier(Modifier),
     /// A button on a mouse
     Mouse(MouseButton),
     /// A discretized mousewheel movement
@@ -322,6 +352,43 @@ impl From<MouseMotionDirection> for InputKind {
     }
 }
 
+impl From<Modifier> for InputKind {
+    fn from(input: Modifier) -> Self {
+        InputKind::Modifier(input)
+    }
+}
+
+/// A keyboard modifier that combines two [`KeyCode`] values into one representation.
+///
+/// This buttonlike input is stored in [`InputKind`], and will be triggered whenever either of these buttons are pressed.
+/// This will be decomposed into both values when converted into [`RawInputs`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum Modifier {
+    /// Corresponds to [`KeyCode::LAlt`] and [`KeyCode::RAlt`].
+    Alt,
+    /// Corresponds to [`KeyCode::LControl`] and [`KeyCode::RControl`].
+    Control,
+    /// The key that makes letters capitalized, corresponding to [`KeyCode::LShift`] and [`KeyCode::RShift`]
+    Shift,
+    /// The OS or "Windows" key, corresponding to [`KeyCode::LWin`] and [`KeyCode::RWin`].
+    Win,
+}
+
+impl Modifier {
+    /// Returns the pair of [`KeyCode`] values associated with this modifier.
+    ///
+    /// The left variant will always be in the first position, and the right variant is always in the second position.
+    #[inline]
+    pub fn key_codes(self) -> [KeyCode; 2] {
+        match self {
+            Modifier::Alt => [KeyCode::LAlt, KeyCode::RAlt],
+            Modifier::Control => [KeyCode::LControl, KeyCode::RControl],
+            Modifier::Shift => [KeyCode::LShift, KeyCode::RShift],
+            Modifier::Win => [KeyCode::LWin, KeyCode::RWin],
+        }
+    }
+}
+
 /// The basic input events that make up a [`UserInput`].
 ///
 /// Obtained by calling [`UserInput::raw_inputs()`].
@@ -493,6 +560,20 @@ mod raw_input_tests {
             let raw = UserInput::from(button).raw_inputs();
             assert_eq!(expected, raw);
         }
+
+        #[test]
+        fn modifier_key_decomposes_into_both_inputs() {
+            use crate::user_input::Modifier;
+            use bevy::input::keyboard::KeyCode;
+
+            let input = UserInput::modified(Modifier::Control, KeyCode::S);
+            let expected = RawInputs {
+                keycodes: vec![KeyCode::LControl, KeyCode::RControl, KeyCode::S],
+                ..Default::default()
+            };
+            let raw = input.raw_inputs();
+            assert_eq!(expected, raw);
+        }
     }
 
     mod mouse {
diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs
index 3f65185..37e5945 100644
--- a/tools/ci/src/main.rs
+++ b/tools/ci/src/main.rs
@@ -9,11 +9,26 @@ fn main() {
     // See if any code needs to be formatted
     cmd!("cargo fmt --all -- --check")
         .run()
-        .expect("Please run 'cargo fmt --all' to format your code.");
+        .expect("Please run `cargo fmt --all` to format your code.");
 
     // See if clippy has any complaints.
     // - Type complexity must be ignored because we use huge templates for queries
     cmd!("cargo clippy --workspace --all-features -- -D warnings -A clippy::type_complexity")
         .run()
-        .expect("Please fix clippy errors in output above.");
+        .expect("Please fix `cargo clippy` errors with all features enabled.");
+
+    // Check for errors with no features enabled
+    cmd!("cargo check --workspace --no-default-features")
+        .run()
+        .expect("Please fix `cargo check` errors with no features enabled .");
+
+    // Check for errors with default features enabled
+    cmd!("cargo check --workspace")
+        .run()
+        .expect("Please fix `cargo check` errors with default features enabled.");
+
+    // Check the examples with clippy
+    cmd!("cargo clippy --examples -- -D warnings -A clippy::type_complexity")
+        .run()
+        .expect("Please fix `cargo clippy` errors for the examples.");
 }


Test patch:
diff --git a/tests/cooldowns.rs b/tests/cooldowns.rs
new file mode 100644
index 0000000..849f678
--- /dev/null
+++ b/tests/cooldowns.rs
@@ -0,0 +1,222 @@
+// BLOCKED: these tests should set the time manually.
+// Requires https://github.com/bevyengine/bevy/issues/6146 to do so.
+
+use bevy::input::InputPlugin;
+use bevy::prelude::*;
+use bevy::utils::Duration;
+use leafwing_input_manager::prelude::*;
+use std::thread::sleep;
+
+#[derive(Actionlike, Clone, Copy)]
+enum Action {
+    NoCooldown,
+    Short,
+    Long,
+}
+
+impl Action {
+    fn cooldown(&self) -> Option<Cooldown> {
+        match self {
+            Action::NoCooldown => None,
+            Action::Short => Some(Cooldown::from_secs(0.1)),
+            Action::Long => Some(Cooldown::from_secs(1.)),
+        }
+    }
+
+    fn cooldowns() -> Cooldowns<Action> {
+        let mut cd = Cooldowns::default();
+        for action in Action::variants() {
+            if let Some(cooldown) = action.cooldown() {
+                cd.set(cooldown, action);
+            }
+        }
+
+        cd
+    }
+}
+
+fn spawn(mut commands: Commands) {
+    commands.spawn_bundle(InputManagerBundle {
+        cooldowns: Action::cooldowns(),
+        ..default()
+    });
+}
+
+#[test]
+fn cooldowns_on_entity() {
+    use Action::*;
+
+    let mut app = App::new();
+    app.add_plugin(InputManagerPlugin::<Action>::default())
+        .add_plugins(MinimalPlugins)
+        .add_plugin(InputPlugin)
+        .add_startup_system(spawn);
+
+    // Spawn entities
+    app.update();
+
+    // Cooldown start ready
+    let mut query_state = app.world.query::<&mut Cooldowns<Action>>();
+    let mut cooldowns: Mut<Cooldowns<Action>> = query_state.single_mut(&mut app.world);
+    for action in Action::variants() {
+        assert!(cooldowns.ready(action));
+        // Trigger all the cooldowns once
+        cooldowns.trigger(action);
+    }
+
+    app.update();
+
+    // No waiting
+    let mut query_state = app.world.query::<&Cooldowns<Action>>();
+    let cooldowns: &Cooldowns<Action> = query_state.single(&mut app.world);
+    assert!(cooldowns.ready(NoCooldown));
+    assert!(!cooldowns.ready(Short));
+    assert!(!cooldowns.ready(Long));
+
+    sleep(Duration::from_secs_f32(0.2));
+    app.update();
+
+    // Short wait
+    let mut query_state = app.world.query::<&Cooldowns<Action>>();
+    let cooldowns: &Cooldowns<Action> = query_state.single(&mut app.world);
+    assert!(cooldowns.ready(NoCooldown));
+    assert!(cooldowns.ready(Short));
+    assert!(!cooldowns.ready(Long));
+}
+
+#[test]
+fn cooldowns_in_resource() {
+    use Action::*;
+
+    let mut app = App::new();
+    app.add_plugin(InputManagerPlugin::<Action>::default())
+        .add_plugins(MinimalPlugins)
+        .add_plugin(InputPlugin)
+        .insert_resource(Action::cooldowns());
+
+    // Cooldown start ready
+    let mut cooldowns: Mut<Cooldowns<Action>> = app.world.resource_mut();
+    for action in Action::variants() {
+        assert!(cooldowns.ready(action));
+        cooldowns.trigger(action);
+    }
+
+    app.update();
+
+    // No waiting
+    let cooldowns: &Cooldowns<Action> = app.world.resource();
+    assert!(cooldowns.ready(NoCooldown));
+    assert!(!cooldowns.ready(Short));
+    assert!(!cooldowns.ready(Long));
+
+    sleep(Duration::from_secs_f32(0.2));
+    app.update();
+
+    // Short wait
+    let cooldowns: &Cooldowns<Action> = app.world.resource();
+    assert!(cooldowns.ready(NoCooldown));
+    assert!(cooldowns.ready(Short));
+    assert!(!cooldowns.ready(Long));
+}
+
+#[test]
+fn global_cooldowns_tick() {
+    let mut app = App::new();
+    app.add_plugin(InputManagerPlugin::<Action>::default())
+        .add_plugins(MinimalPlugins)
+        .add_plugin(InputPlugin)
+        .insert_resource(Action::cooldowns());
+
+    let mut cooldowns: Mut<Cooldowns<Action>> = app.world.resource_mut();
+    let initial_gcd = Some(Cooldown::new(Duration::from_micros(15)));
+    cooldowns.global_cooldown = initial_gcd.clone();
+    // Trigger the GCD
+    cooldowns.trigger(Action::Long);
+
+    app.update();
+
+    let cooldowns: &Cooldowns<Action> = app.world.resource();
+    assert!(initial_gcd != cooldowns.global_cooldown);
+}
+
+#[test]
+fn global_cooldown_blocks_cooldownless_actions() {
+    let mut app = App::new();
+    app.add_plugin(InputManagerPlugin::<Action>::default())
+        .add_plugins(MinimalPlugins)
+        .add_plugin(InputPlugin)
+        .insert_resource(Action::cooldowns());
+
+    // First delta time provided of each app is wonky
+    app.update();
+
+    let mut cooldowns: Mut<Cooldowns<Action>> = app.world.resource_mut();
+    cooldowns.global_cooldown = Some(Cooldown::new(Duration::from_micros(15)));
+
+    assert!(cooldowns.ready(Action::NoCooldown));
+
+    cooldowns.trigger(Action::NoCooldown);
+    assert!(!cooldowns.ready(Action::NoCooldown));
+
+    sleep(Duration::from_micros(30));
+    app.update();
+
+    let cooldowns: &Cooldowns<Action> = app.world.resource();
+    assert!(cooldowns.ready(Action::NoCooldown));
+}
+
+#[test]
+fn global_cooldown_affects_other_actions() {
+    let mut app = App::new();
+    app.add_plugin(InputManagerPlugin::<Action>::default())
+        .add_plugins(MinimalPlugins)
+        .add_plugin(InputPlugin)
+        .insert_resource(Action::cooldowns());
+
+    // First delta time provided of each app is wonky
+    app.update();
+
+    let mut cooldowns: Mut<Cooldowns<Action>> = app.world.resource_mut();
+    cooldowns.global_cooldown = Some(Cooldown::new(Duration::from_micros(15)));
+    cooldowns.trigger(Action::Long);
+    assert!(!cooldowns.ready(Action::Short));
+    assert!(!cooldowns.ready(Action::Long));
+
+    sleep(Duration::from_micros(30));
+    app.update();
+
+    let cooldowns: &Cooldowns<Action> = app.world.resource();
+    assert!(cooldowns.ready(Action::Short));
+    assert!(!cooldowns.ready(Action::Long));
+}
+
+#[test]
+fn global_cooldown_overrides_short_cooldowns() {
+    let mut app = App::new();
+    app.add_plugin(InputManagerPlugin::<Action>::default())
+        .add_plugins(MinimalPlugins)
+        .add_plugin(InputPlugin)
+        .insert_resource(Action::cooldowns());
+
+    // First delta time provided of each app is wonky
+    app.update();
+
+    let mut cooldowns: Mut<Cooldowns<Action>> = app.world.resource_mut();
+    cooldowns.global_cooldown = Some(Cooldown::from_secs(0.5));
+    cooldowns.trigger(Action::Short);
+    assert!(!cooldowns.ready(Action::Short));
+
+    // Let per-action cooldown elapse
+    sleep(Duration::from_millis(200));
+    app.update();
+
+    let cooldowns: &Cooldowns<Action> = app.world.resource();
+    assert!(!cooldowns.ready(Action::Short));
+
+    // Wait for full GCD to expire
+    sleep(Duration::from_millis(400));
+    app.update();
+
+    let cooldowns: &Cooldowns<Action> = app.world.resource();
+    assert!(cooldowns.ready(Action::Short));
+}
diff --git a/tests/integration.rs b/tests/integration.rs
index 2e83441..0d70493 100644
--- a/tests/integration.rs
+++ b/tests/integration.rs
@@ -33,6 +33,12 @@ fn respect_fades(mut respect: ResMut<Respect>) {
     respect.0 = false;
 }
 
+fn remove_input_map(mut commands: Commands, query: Query<Entity, With<InputMap<Action>>>) {
+    for entity in query.iter() {
+        commands.entity(entity).remove::<InputMap<Action>>();
+    }
+}
+
 #[derive(Component)]
 struct Player;
 
@@ -162,6 +168,47 @@ fn disable_input() {
     assert_eq!(*respect, Respect(false));
 }
 
+#[test]
+fn release_when_input_map_removed() {
+    use bevy::input::InputPlugin;
+
+    let mut app = App::new();
+
+    // Spawn a player and create a global action state.
+    app.add_plugins(MinimalPlugins)
+        .add_plugin(InputPlugin)
+        .add_plugin(InputManagerPlugin::<Action>::default())
+        .add_startup_system(spawn_player)
+        .init_resource::<ActionState<Action>>()
+        .insert_resource(InputMap::<Action>::new([(KeyCode::F, Action::PayRespects)]))
+        .init_resource::<Respect>()
+        .add_system(pay_respects)
+        .add_system(remove_input_map)
+        .add_system_to_stage(CoreStage::PreUpdate, respect_fades);
+
+    // Press F to pay respects
+    app.send_input(KeyCode::F);
+    app.update();
+    let respect = app.world.resource::<Respect>();
+    assert_eq!(*respect, Respect(true));
+
+    // Remove the InputMap
+    app.world.remove_resource::<InputMap<Action>>();
+    // Needs an extra frame for the resource removed detection to release inputs
+    app.update();
+
+    // Now, all respect has faded
+    app.update();
+    let respect = app.world.resource::<Respect>();
+    assert_eq!(*respect, Respect(false));
+
+    // And even pressing F cannot bring it back
+    app.send_input(KeyCode::F);
+    app.update();
+    let respect = app.world.resource::<Respect>();
+    assert_eq!(*respect, Respect(false));
+}
+
 #[test]
 #[cfg(feature = "ui")]
 fn action_state_driver() {