Skip to main content

entracte_lib/scheduler/
hotkeys.rs

1//! Native global hotkeys (#150).
2//!
3//! Lets the user bind OS-level global shortcuts to the same actions the CLI
4//! exposes (pause/resume, trigger/skip a break, cycle profile), so a break
5//! can be driven from the keyboard whether or not the Preferences window is
6//! focused. Bindings live in `Settings` (`hotkeys_enabled` + `hotkeys`) and
7//! are registered on the **backend** via `tauri-plugin-global-shortcut`, so
8//! they keep working with the window hidden — the renderer's webview can't be
9//! relied on for this.
10//!
11//! The pure pieces — which bindings to register
12//! ([`registrable_bindings`]) and which profile a "cycle" lands on
13//! ([`next_profile_name`]) — are unit-tested here; the actual OS
14//! registration in [`apply_hotkeys`] is the thin, uncovered FFI shim.
15
16use serde::{Deserialize, Serialize};
17#[cfg(not(test))]
18use tauri::Manager;
19use tauri::{AppHandle, Emitter, Runtime};
20
21use super::settings::Settings;
22use super::types::BreakKind;
23use super::Scheduler;
24
25/// An action a global hotkey can fire. Mirrors the local CLI actions so a
26/// chord is just another route to the same behaviour (CLI parity).
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
28#[serde(rename_all = "snake_case")]
29pub enum HotkeyAction {
30    Pause,
31    #[serde(rename = "pause_15m")]
32    Pause15m,
33    #[serde(rename = "pause_30m")]
34    Pause30m,
35    #[serde(rename = "pause_60m")]
36    Pause60m,
37    Resume,
38    TriggerMicro,
39    TriggerLong,
40    SkipMicro,
41    SkipLong,
42    CycleProfile,
43}
44
45impl HotkeyAction {
46    /// For a pause action, the pause length: `Some(None)` is an indefinite
47    /// pause, `Some(Some(secs))` a timed one; `None` for non-pause actions.
48    /// Pure so the duration mapping is unit-testable. The timed values mirror
49    /// the `entracte pause <dur>` / `IpcRequest::Pause { duration_secs }` path.
50    fn pause_duration_secs(self) -> Option<Option<u64>> {
51        match self {
52            HotkeyAction::Pause => Some(None),
53            HotkeyAction::Pause15m => Some(Some(15 * 60)),
54            HotkeyAction::Pause30m => Some(Some(30 * 60)),
55            HotkeyAction::Pause60m => Some(Some(60 * 60)),
56            _ => None,
57        }
58    }
59}
60
61/// A single binding: an [`HotkeyAction`] and the accelerator that triggers
62/// it (tauri-plugin-global-shortcut syntax, e.g. `"CmdOrCtrl+Alt+P"`). An
63/// empty accelerator means the action is unbound.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct Hotkey {
66    pub action: HotkeyAction,
67    pub accelerator: String,
68}
69
70/// Canonical form of an accelerator for conflict comparison:
71/// case-insensitive and modifier-order-insensitive. Mirrors the renderer's
72/// `normalizeAccelerator` (`src/lib/hotkeys.ts`) so the in-app conflict
73/// warning and the backend's conflict handling agree.
74fn normalize_accelerator(accelerator: &str) -> String {
75    let mut parts: Vec<String> = accelerator
76        .split('+')
77        .map(|p| p.trim().to_lowercase())
78        .filter(|p| !p.is_empty())
79        .collect();
80    parts.sort();
81    parts.join("+")
82}
83
84/// The bindings that should actually be registered with the OS: only when
85/// hotkeys are enabled, only entries with a non-blank accelerator, and only
86/// chords bound to exactly one action. A chord bound to two or more actions
87/// is dropped entirely (none of them fire) so behaviour is unambiguous and
88/// matches the conflict the renderer flags — rather than letting whichever
89/// action registers first silently win. Pure so the gating is unit-testable
90/// without touching the OS.
91pub fn registrable_bindings(s: &Settings) -> Vec<Hotkey> {
92    if !s.hotkeys_enabled {
93        return Vec::new();
94    }
95    let candidates: Vec<&Hotkey> = s
96        .hotkeys
97        .iter()
98        .filter(|h| !h.accelerator.trim().is_empty())
99        .collect();
100    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
101    for h in &candidates {
102        *counts
103            .entry(normalize_accelerator(&h.accelerator))
104            .or_insert(0) += 1;
105    }
106    candidates
107        .into_iter()
108        .filter(|h| counts.get(&normalize_accelerator(&h.accelerator)) == Some(&1))
109        .cloned()
110        .collect()
111}
112
113/// The profile that a "cycle profile" hotkey should switch to: the one after
114/// `active` in `names`, wrapping around to the first. `None` when there are
115/// no profiles or the active name isn't found (nothing sensible to do). Pure
116/// so the wrap-around is unit-testable.
117pub fn next_profile_name(names: &[String], active: &str) -> Option<String> {
118    let pos = names.iter().position(|n| n == active)?;
119    let next = (pos + 1) % names.len();
120    names.get(next).cloned()
121}
122
123/// Run the action a hotkey is bound to, going through the same scheduler
124/// entry points the CLI/IPC use so the two paths stay in lockstep. Pause and
125/// resume mirror the IPC handler's pause-state writes; trigger/skip and the
126/// profile cycle reuse the shared command helpers.
127///
128/// In the real build this is called from `apply_hotkeys`'s shortcut handler;
129/// under `cfg(test)` that shim is a no-op, so the only callers are the
130/// `mod execute` tests — which are skipped on Windows (the mock-app rig isn't
131/// compiled there). Allow dead_code in exactly that build so `-D warnings`
132/// doesn't fail the Windows test compile.
133#[cfg_attr(all(test, target_os = "windows"), allow(dead_code))]
134pub async fn execute_hotkey_action<R: Runtime>(
135    app: &AppHandle<R>,
136    scheduler: &Scheduler,
137    action: HotkeyAction,
138) {
139    match action {
140        HotkeyAction::Pause
141        | HotkeyAction::Pause15m
142        | HotkeyAction::Pause30m
143        | HotkeyAction::Pause60m => {
144            // `flatten()`: outer `Some` = a pause action, inner `Option<u64>`
145            // = indefinite (`None`) vs timed. Route through the canonical
146            // `pause_impl` so the pause persists, logs `PauseStart`, and fires
147            // `pause_start` hooks like the Settings button (#218).
148            super::pause_impl(scheduler, action.pause_duration_secs().flatten()).await;
149            let _ = app.emit("pause:changed", true);
150        }
151        HotkeyAction::Resume => {
152            super::resume_impl(scheduler).await;
153            let _ = app.emit("pause:changed", false);
154        }
155        HotkeyAction::TriggerMicro => {
156            let secs = scheduler.settings.lock().await.micro_duration_secs;
157            super::trigger_break_from_cli(app, scheduler, BreakKind::Micro, secs).await;
158        }
159        HotkeyAction::TriggerLong => {
160            let secs = scheduler.settings.lock().await.long_duration_secs;
161            super::trigger_break_from_cli(app, scheduler, BreakKind::Long, secs).await;
162        }
163        HotkeyAction::SkipMicro => {
164            let _ = super::skip_next_from_cli(app, scheduler, BreakKind::Micro).await;
165        }
166        HotkeyAction::SkipLong => {
167            let _ = super::skip_next_from_cli(app, scheduler, BreakKind::Long).await;
168        }
169        HotkeyAction::CycleProfile => {
170            let names: Vec<String> = scheduler
171                .profiles
172                .lock()
173                .await
174                .iter()
175                .map(|p| p.name.clone())
176                .collect();
177            let active = scheduler.active_profile_name.lock().await.clone();
178            if let Some(next) = next_profile_name(&names, &active) {
179                let _ = super::set_active_profile_impl(app, scheduler, next).await;
180            }
181        }
182    }
183}
184
185/// (Re)register the enabled global shortcuts for the active settings,
186/// clearing any previously-registered ones first. Each binding is registered
187/// with its own handler that fires [`execute_hotkey_action`] on key-down.
188///
189/// This is the OS-FFI shim: it talks to `tauri-plugin-global-shortcut`, so
190/// it's compiled out of the test build (the plugin isn't registered under the
191/// mock runtime, and a real registration would grab system-wide chords during
192/// `cargo test`). The decision of *what* to register is the pure
193/// [`registrable_bindings`], which is tested.
194#[cfg(not(test))]
195pub fn apply_hotkeys<R: Runtime>(app: &AppHandle<R>, settings: &Settings) {
196    use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
197
198    let manager = app.global_shortcut();
199    if let Err(e) = manager.unregister_all() {
200        log::warn!("hotkeys: failed to clear existing shortcuts: {e}");
201    }
202    for binding in registrable_bindings(settings) {
203        let accelerator = binding.accelerator.clone();
204        let action = binding.action;
205        let registered = manager.on_shortcut(accelerator.as_str(), move |app, _shortcut, event| {
206            if event.state != ShortcutState::Pressed {
207                return;
208            }
209            let app = app.clone();
210            tauri::async_runtime::spawn(async move {
211                let Some(scheduler) = app.try_state::<Scheduler>() else {
212                    return;
213                };
214                let scheduler = scheduler.inner().clone();
215                execute_hotkey_action(&app, &scheduler, action).await;
216            });
217        });
218        if let Err(e) = registered {
219            log::warn!("hotkeys: failed to register '{accelerator}': {e}");
220        }
221    }
222}
223
224#[cfg(test)]
225pub fn apply_hotkeys<R: Runtime>(_app: &AppHandle<R>, _settings: &Settings) {}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn hk(action: HotkeyAction, accel: &str) -> Hotkey {
232        Hotkey {
233            action,
234            accelerator: accel.to_string(),
235        }
236    }
237
238    #[test]
239    #[allow(clippy::field_reassign_with_default)]
240    fn registrable_bindings_empty_when_disabled() {
241        let mut s = Settings::default();
242        s.hotkeys = vec![hk(HotkeyAction::Pause, "CmdOrCtrl+Alt+P")];
243        s.hotkeys_enabled = false;
244        assert!(registrable_bindings(&s).is_empty());
245    }
246
247    #[test]
248    #[allow(clippy::field_reassign_with_default)]
249    fn registrable_bindings_drops_blank_accelerators() {
250        let mut s = Settings::default();
251        s.hotkeys_enabled = true;
252        s.hotkeys = vec![
253            hk(HotkeyAction::Pause, "CmdOrCtrl+Alt+P"),
254            hk(HotkeyAction::Resume, ""),
255            hk(HotkeyAction::SkipMicro, "   "),
256            hk(HotkeyAction::TriggerLong, "CmdOrCtrl+Alt+L"),
257        ];
258        let got = registrable_bindings(&s);
259        assert_eq!(got.len(), 2);
260        assert_eq!(got[0].action, HotkeyAction::Pause);
261        assert_eq!(got[1].action, HotkeyAction::TriggerLong);
262    }
263
264    #[test]
265    #[allow(clippy::field_reassign_with_default)]
266    fn registrable_bindings_drops_conflicting_chords() {
267        let mut s = Settings::default();
268        s.hotkeys_enabled = true;
269        s.hotkeys = vec![
270            // Same chord (modifier order differs) on two actions — both dropped.
271            hk(HotkeyAction::Pause, "CmdOrCtrl+Alt+P"),
272            hk(HotkeyAction::Resume, "Alt+CmdOrCtrl+P"),
273            // A distinct, unique chord survives.
274            hk(HotkeyAction::SkipMicro, "CmdOrCtrl+Alt+M"),
275        ];
276        let got = registrable_bindings(&s);
277        assert_eq!(got.len(), 1);
278        assert_eq!(got[0].action, HotkeyAction::SkipMicro);
279    }
280
281    #[test]
282    fn next_profile_name_wraps_around() {
283        let names = vec!["A".to_string(), "B".to_string(), "C".to_string()];
284        assert_eq!(next_profile_name(&names, "A").as_deref(), Some("B"));
285        assert_eq!(next_profile_name(&names, "B").as_deref(), Some("C"));
286        // Wraps back to the first after the last.
287        assert_eq!(next_profile_name(&names, "C").as_deref(), Some("A"));
288    }
289
290    #[test]
291    fn pause_duration_secs_maps_each_pause_action() {
292        assert_eq!(HotkeyAction::Pause.pause_duration_secs(), Some(None));
293        assert_eq!(
294            HotkeyAction::Pause15m.pause_duration_secs(),
295            Some(Some(900))
296        );
297        assert_eq!(
298            HotkeyAction::Pause30m.pause_duration_secs(),
299            Some(Some(1800))
300        );
301        assert_eq!(
302            HotkeyAction::Pause60m.pause_duration_secs(),
303            Some(Some(3600))
304        );
305        // Non-pause actions carry no pause duration.
306        assert_eq!(HotkeyAction::Resume.pause_duration_secs(), None);
307        assert_eq!(HotkeyAction::CycleProfile.pause_duration_secs(), None);
308    }
309
310    #[test]
311    fn pause_actions_serialize_to_their_on_disk_strings() {
312        let json = serde_json::to_string(&HotkeyAction::Pause15m).unwrap();
313        assert_eq!(json, "\"pause_15m\"");
314        let back: HotkeyAction = serde_json::from_str("\"pause_60m\"").unwrap();
315        assert_eq!(back, HotkeyAction::Pause60m);
316    }
317
318    #[test]
319    fn next_profile_name_handles_single_profile() {
320        let names = vec!["Only".to_string()];
321        // One profile: cycling lands back on itself.
322        assert_eq!(next_profile_name(&names, "Only").as_deref(), Some("Only"));
323    }
324
325    #[test]
326    fn next_profile_name_none_when_active_unknown_or_empty() {
327        let names = vec!["A".to_string(), "B".to_string()];
328        assert_eq!(next_profile_name(&names, "Missing"), None);
329        assert_eq!(next_profile_name(&[], "A"), None);
330    }
331
332    // The action-execution path needs an `AppHandle` (event emission,
333    // `State` lookup), so it runs through the mock-app rig. Gated off
334    // Windows for the same reason as the other mock-app tests (see
335    // `test_support`).
336    #[cfg(not(target_os = "windows"))]
337    mod execute {
338        use super::*;
339        use crate::config::{Profile, DEFAULT_PROFILE_NAME};
340        use crate::scheduler::PauseState;
341        use crate::test_support::{mock_app_with_scheduler, test_scheduler_with_profiles};
342
343        #[tokio::test]
344        async fn pause_then_resume_toggles_pause_state() {
345            let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
346
347            execute_hotkey_action(app.handle(), &sched, HotkeyAction::Pause).await;
348            assert!(matches!(
349                *sched.pause_state.lock().await,
350                PauseState::PausedUntil(None)
351            ));
352
353            execute_hotkey_action(app.handle(), &sched, HotkeyAction::Resume).await;
354            assert!(matches!(
355                *sched.pause_state.lock().await,
356                PauseState::Running
357            ));
358        }
359
360        #[tokio::test]
361        async fn pause_routes_through_pause_impl_and_persists() {
362            // Routing through `pause_impl` persists the pause to disk (and
363            // logs `PauseStart` + fires hooks — both covered in breaks.rs).
364            // The previous direct `pause_state` write did none of that, so a
365            // persisted pause file is proof the hotkey now takes the canonical
366            // path (#218).
367            let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
368            execute_hotkey_action(app.handle(), &sched, HotkeyAction::Pause30m).await;
369            let snap = crate::pause_store::load(&sched.pause_path);
370            assert!(snap.paused, "hotkey pause should persist as paused");
371            assert!(
372                snap.until_epoch_secs.is_some(),
373                "timed pause has a deadline"
374            );
375
376            execute_hotkey_action(app.handle(), &sched, HotkeyAction::Resume).await;
377            let snap = crate::pause_store::load(&sched.pause_path);
378            assert!(!snap.paused, "hotkey resume should persist as running");
379        }
380
381        #[tokio::test]
382        async fn timed_pause_sets_a_future_deadline() {
383            let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
384            let before = std::time::Instant::now();
385            execute_hotkey_action(app.handle(), &sched, HotkeyAction::Pause30m).await;
386            let state = sched.pause_state.lock().await.clone();
387            match state {
388                PauseState::PausedUntil(Some(until)) => {
389                    // ~30 minutes out, with slack for execution time.
390                    assert!(until > before + std::time::Duration::from_secs(29 * 60));
391                    assert!(until <= before + std::time::Duration::from_secs(31 * 60));
392                }
393                ref other => panic!("expected a timed pause, got {other:?}"),
394            }
395        }
396
397        #[tokio::test]
398        async fn trigger_and_skip_actions_run_without_panicking() {
399            // Notification delivery avoids the overlay's monitor enumeration
400            // (`available_monitors` is unimplemented under MockRuntime), so the
401            // trigger/skip arms can be exercised end to end here. No break is
402            // pending, so skipping is a no-op — but both arms must not panic.
403            use crate::scheduler::settings::BreakMode;
404            let settings = Settings {
405                micro_break_mode: BreakMode::Notification,
406                long_break_mode: BreakMode::Notification,
407                ..Settings::default()
408            };
409            let (_dir, app, sched) = mock_app_with_scheduler(settings);
410
411            execute_hotkey_action(app.handle(), &sched, HotkeyAction::TriggerMicro).await;
412            execute_hotkey_action(app.handle(), &sched, HotkeyAction::TriggerLong).await;
413            execute_hotkey_action(app.handle(), &sched, HotkeyAction::SkipMicro).await;
414            execute_hotkey_action(app.handle(), &sched, HotkeyAction::SkipLong).await;
415        }
416
417        #[tokio::test]
418        async fn cycle_profile_advances_to_the_next_profile() {
419            let profiles = vec![
420                Profile {
421                    name: DEFAULT_PROFILE_NAME.to_string(),
422                    settings: Settings::default(),
423                },
424                Profile {
425                    name: "Focus".to_string(),
426                    settings: Settings::default(),
427                },
428            ];
429            let (_dir, sched) = test_scheduler_with_profiles(profiles, DEFAULT_PROFILE_NAME);
430            let app = crate::test_support::wrap_in_mock_app(sched.clone());
431
432            execute_hotkey_action(app.handle(), &sched, HotkeyAction::CycleProfile).await;
433            assert_eq!(*sched.active_profile_name.lock().await, "Focus");
434
435            // Cycling again wraps back to the first profile.
436            execute_hotkey_action(app.handle(), &sched, HotkeyAction::CycleProfile).await;
437            assert_eq!(
438                *sched.active_profile_name.lock().await,
439                DEFAULT_PROFILE_NAME
440            );
441        }
442    }
443}