1use 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#[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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct Hotkey {
66 pub action: HotkeyAction,
67 pub accelerator: String,
68}
69
70fn 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
84pub 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
113pub 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#[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 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#[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 hk(HotkeyAction::Pause, "CmdOrCtrl+Alt+P"),
272 hk(HotkeyAction::Resume, "Alt+CmdOrCtrl+P"),
273 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 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 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 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 #[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 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 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 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 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}