This commit is contained in:
@@ -51,6 +51,27 @@ pub struct TimerSnapshot {
|
||||
pub natural_break_occurred: bool,
|
||||
pub smart_breaks_enabled: bool,
|
||||
pub smart_break_threshold: u32,
|
||||
// F1: Microbreaks
|
||||
pub microbreak_enabled: bool,
|
||||
pub microbreak_active: bool,
|
||||
pub microbreak_time_remaining: u64,
|
||||
pub microbreak_total_duration: u64,
|
||||
pub microbreak_countdown: u64, // seconds until next microbreak
|
||||
pub microbreak_frequency: u32,
|
||||
// F3: Pomodoro
|
||||
pub pomodoro_enabled: bool,
|
||||
pub pomodoro_cycle_position: u32,
|
||||
pub pomodoro_total_in_cycle: u32,
|
||||
pub pomodoro_is_long_break: bool,
|
||||
pub pomodoro_next_is_long: bool,
|
||||
// F5: Screen dimming
|
||||
pub screen_dim_active: bool,
|
||||
pub screen_dim_progress: f64,
|
||||
// F2: Presentation mode
|
||||
pub presentation_mode_active: bool,
|
||||
pub deferred_break_pending: bool,
|
||||
// F10: Gamification
|
||||
pub is_long_break: bool,
|
||||
}
|
||||
|
||||
/// Events emitted by the timer to the frontend
|
||||
@@ -63,6 +84,7 @@ pub struct BreakStartedPayload {
|
||||
pub strict_mode: bool,
|
||||
pub snooze_duration: u32,
|
||||
pub fullscreen_mode: bool,
|
||||
pub is_long_break: bool,
|
||||
}
|
||||
|
||||
pub struct TimerManager {
|
||||
@@ -84,6 +106,17 @@ pub struct TimerManager {
|
||||
// Smart breaks: track when idle started for natural break detection
|
||||
pub idle_start_time: Option<Instant>,
|
||||
pub natural_break_occurred: bool,
|
||||
// F1: Microbreaks
|
||||
pub microbreak_time_remaining: u64,
|
||||
pub microbreak_active: bool,
|
||||
pub microbreak_time_until_end: u64,
|
||||
pub microbreak_total_duration: u64,
|
||||
// F3: Pomodoro
|
||||
pub pomodoro_cycle_position: u32,
|
||||
pub pomodoro_is_long_break: bool,
|
||||
// F2: Presentation mode
|
||||
pub presentation_mode_active: bool,
|
||||
pub deferred_break_pending: bool,
|
||||
}
|
||||
|
||||
impl TimerManager {
|
||||
@@ -92,6 +125,7 @@ impl TimerManager {
|
||||
let freq = config.break_frequency_seconds();
|
||||
let pending = config.clone();
|
||||
let auto_start = config.auto_start;
|
||||
let microbreak_freq = config.microbreak_frequency as u64 * 60;
|
||||
|
||||
Self {
|
||||
state: if auto_start {
|
||||
@@ -113,6 +147,17 @@ impl TimerManager {
|
||||
idle_paused: false,
|
||||
idle_start_time: None,
|
||||
natural_break_occurred: false,
|
||||
// F1: Microbreaks
|
||||
microbreak_time_remaining: microbreak_freq,
|
||||
microbreak_active: false,
|
||||
microbreak_time_until_end: 0,
|
||||
microbreak_total_duration: 0,
|
||||
// F3: Pomodoro
|
||||
pomodoro_cycle_position: 0,
|
||||
pomodoro_is_long_break: false,
|
||||
// F2: Presentation mode
|
||||
presentation_mode_active: false,
|
||||
deferred_break_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,6 +218,18 @@ impl TimerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// F2: Check if the foreground window is fullscreen (presentation mode)
|
||||
pub fn check_presentation_mode(&mut self) -> bool {
|
||||
if !self.config.presentation_mode_enabled {
|
||||
self.presentation_mode_active = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
let fs = is_foreground_fullscreen();
|
||||
self.presentation_mode_active = fs;
|
||||
fs
|
||||
}
|
||||
|
||||
/// Called every second. Returns what events should be emitted.
|
||||
pub fn tick(&mut self) -> TickResult {
|
||||
// Idle detection and natural break detection
|
||||
@@ -223,15 +280,18 @@ impl TimerManager {
|
||||
TickResult::None
|
||||
}
|
||||
} else {
|
||||
// F2: Check presentation mode before starting break
|
||||
if self.check_presentation_mode() {
|
||||
self.deferred_break_pending = true;
|
||||
// Keep time at 0 so it triggers immediately when cleared
|
||||
self.time_until_next_break = 0;
|
||||
return TickResult::BreakDeferred;
|
||||
}
|
||||
|
||||
// Clear any deferred state
|
||||
self.deferred_break_pending = false;
|
||||
self.start_break();
|
||||
TickResult::BreakStarted(BreakStartedPayload {
|
||||
title: self.config.break_title.clone(),
|
||||
message: self.config.break_message.clone(),
|
||||
duration: self.break_total_duration,
|
||||
strict_mode: self.config.strict_mode,
|
||||
snooze_duration: self.config.snooze_duration,
|
||||
fullscreen_mode: self.config.fullscreen_mode,
|
||||
})
|
||||
TickResult::BreakStarted(self.make_break_payload())
|
||||
}
|
||||
}
|
||||
TimerState::BreakActive => {
|
||||
@@ -242,28 +302,139 @@ impl TimerManager {
|
||||
// Break completed naturally
|
||||
self.has_had_break = true;
|
||||
self.seconds_since_last_break = 0;
|
||||
self.advance_pomodoro_cycle();
|
||||
self.reset_timer();
|
||||
TickResult::BreakEnded
|
||||
}
|
||||
}
|
||||
TimerState::Paused => TickResult::None,
|
||||
TimerState::Paused => {
|
||||
// F2: Check if deferred break can now proceed
|
||||
if self.deferred_break_pending && !self.idle_paused {
|
||||
if !self.check_presentation_mode() {
|
||||
self.deferred_break_pending = false;
|
||||
self.state = TimerState::Running;
|
||||
self.start_break();
|
||||
return TickResult::BreakStarted(self.make_break_payload());
|
||||
}
|
||||
}
|
||||
TickResult::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// F1: Called every second for microbreak logic. Returns microbreak events.
|
||||
pub fn tick_microbreak(&mut self) -> MicrobreakTickResult {
|
||||
if !self.config.microbreak_enabled {
|
||||
return MicrobreakTickResult::None;
|
||||
}
|
||||
|
||||
// Don't tick microbreaks during main breaks
|
||||
if self.config.microbreak_pause_during_break && self.state == TimerState::BreakActive {
|
||||
return MicrobreakTickResult::None;
|
||||
}
|
||||
|
||||
// Don't tick when manually paused (but not during idle-pause which auto-resumes)
|
||||
if self.state == TimerState::Paused && !self.idle_paused {
|
||||
return MicrobreakTickResult::None;
|
||||
}
|
||||
|
||||
// F2: Defer microbreaks during presentation mode
|
||||
if self.config.presentation_mode_defer_microbreaks && self.presentation_mode_active {
|
||||
return MicrobreakTickResult::None;
|
||||
}
|
||||
|
||||
if self.microbreak_active {
|
||||
// Counting down microbreak
|
||||
if self.microbreak_time_until_end > 0 {
|
||||
self.microbreak_time_until_end -= 1;
|
||||
MicrobreakTickResult::None
|
||||
} else {
|
||||
// Microbreak ended
|
||||
self.microbreak_active = false;
|
||||
self.reset_microbreak_timer();
|
||||
MicrobreakTickResult::MicrobreakEnded
|
||||
}
|
||||
} else {
|
||||
// Counting down to next microbreak
|
||||
if self.microbreak_time_remaining > 0 {
|
||||
self.microbreak_time_remaining -= 1;
|
||||
MicrobreakTickResult::None
|
||||
} else {
|
||||
// Start microbreak
|
||||
self.microbreak_active = true;
|
||||
self.microbreak_total_duration = self.config.microbreak_duration as u64;
|
||||
self.microbreak_time_until_end = self.microbreak_total_duration;
|
||||
MicrobreakTickResult::MicrobreakStarted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_microbreak_timer(&mut self) {
|
||||
self.microbreak_time_remaining = self.config.microbreak_frequency as u64 * 60;
|
||||
}
|
||||
|
||||
fn make_break_payload(&self) -> BreakStartedPayload {
|
||||
BreakStartedPayload {
|
||||
title: if self.pomodoro_is_long_break {
|
||||
self.config.pomodoro_long_break_title.clone()
|
||||
} else {
|
||||
self.config.break_title.clone()
|
||||
},
|
||||
message: if self.pomodoro_is_long_break {
|
||||
self.config.pomodoro_long_break_message.clone()
|
||||
} else {
|
||||
self.config.break_message.clone()
|
||||
},
|
||||
duration: self.break_total_duration,
|
||||
strict_mode: self.config.strict_mode,
|
||||
snooze_duration: self.config.snooze_duration,
|
||||
fullscreen_mode: self.config.fullscreen_mode,
|
||||
is_long_break: self.pomodoro_is_long_break,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_break(&mut self) {
|
||||
// F3: Determine if this should be a long break (Pomodoro)
|
||||
if self.config.pomodoro_enabled {
|
||||
// cycle_position counts from 0. Position == short_breaks means it's the long break.
|
||||
self.pomodoro_is_long_break = self.pomodoro_cycle_position >= self.config.pomodoro_short_breaks;
|
||||
} else {
|
||||
self.pomodoro_is_long_break = false;
|
||||
}
|
||||
|
||||
self.state = TimerState::BreakActive;
|
||||
self.current_view = AppView::BreakScreen;
|
||||
self.break_total_duration = self.config.break_duration_seconds();
|
||||
|
||||
if self.pomodoro_is_long_break {
|
||||
self.break_total_duration = self.config.pomodoro_long_break_duration as u64 * 60;
|
||||
} else {
|
||||
self.break_total_duration = self.config.break_duration_seconds();
|
||||
}
|
||||
|
||||
self.time_until_break_end = self.break_total_duration;
|
||||
self.prebreak_notification_active = false;
|
||||
self.snoozes_used = 0;
|
||||
}
|
||||
|
||||
/// F3: Advance the Pomodoro cycle position after a break completes
|
||||
fn advance_pomodoro_cycle(&mut self) {
|
||||
if !self.config.pomodoro_enabled {
|
||||
return;
|
||||
}
|
||||
if self.pomodoro_is_long_break {
|
||||
// After long break, reset cycle
|
||||
self.pomodoro_cycle_position = 0;
|
||||
} else {
|
||||
self.pomodoro_cycle_position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_timer(&mut self) {
|
||||
self.state = TimerState::Running;
|
||||
self.current_view = AppView::Dashboard;
|
||||
self.time_until_next_break = self.config.break_frequency_seconds();
|
||||
self.prebreak_notification_active = false;
|
||||
self.pomodoro_is_long_break = false;
|
||||
}
|
||||
|
||||
pub fn toggle_timer(&mut self) {
|
||||
@@ -281,14 +452,7 @@ impl TimerManager {
|
||||
pub fn start_break_now(&mut self) -> Option<BreakStartedPayload> {
|
||||
if self.state == TimerState::Running || self.state == TimerState::Paused {
|
||||
self.start_break();
|
||||
Some(BreakStartedPayload {
|
||||
title: self.config.break_title.clone(),
|
||||
message: self.config.break_message.clone(),
|
||||
duration: self.break_total_duration,
|
||||
strict_mode: self.config.strict_mode,
|
||||
snooze_duration: self.config.snooze_duration,
|
||||
fullscreen_mode: self.config.fullscreen_mode,
|
||||
})
|
||||
Some(self.make_break_payload())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -311,10 +475,15 @@ impl TimerManager {
|
||||
// "End break" - counts as completed
|
||||
self.has_had_break = true;
|
||||
self.seconds_since_last_break = 0;
|
||||
self.advance_pomodoro_cycle();
|
||||
self.reset_timer();
|
||||
true
|
||||
} else if !past_half {
|
||||
// "Cancel break" - doesn't count
|
||||
// F3: Pomodoro reset-on-skip
|
||||
if self.config.pomodoro_enabled && self.config.pomodoro_reset_on_skip {
|
||||
self.pomodoro_cycle_position = 0;
|
||||
}
|
||||
self.reset_timer();
|
||||
true
|
||||
} else {
|
||||
@@ -420,6 +589,25 @@ impl TimerManager {
|
||||
&self.config
|
||||
};
|
||||
|
||||
// F5: Screen dim active when running and close to break
|
||||
let dim_secs = self.config.screen_dim_seconds as u64;
|
||||
let screen_dim_active = self.config.screen_dim_enabled
|
||||
&& self.state == TimerState::Running
|
||||
&& self.time_until_next_break <= dim_secs
|
||||
&& self.time_until_next_break > 0
|
||||
&& !self.deferred_break_pending;
|
||||
let screen_dim_progress = if screen_dim_active && dim_secs > 0 {
|
||||
1.0 - (self.time_until_next_break as f64 / dim_secs as f64)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// F3: Pomodoro info
|
||||
let pomo_total = self.config.pomodoro_short_breaks + 1;
|
||||
let pomo_next_is_long = self.config.pomodoro_enabled
|
||||
&& self.pomodoro_cycle_position + 1 >= pomo_total
|
||||
&& self.state != TimerState::BreakActive;
|
||||
|
||||
TimerSnapshot {
|
||||
state: self.state,
|
||||
current_view: self.current_view,
|
||||
@@ -431,8 +619,16 @@ impl TimerManager {
|
||||
prebreak_warning: self.prebreak_notification_active,
|
||||
snoozes_used: self.snoozes_used,
|
||||
can_snooze: self.can_snooze(),
|
||||
break_title: display_config.break_title.clone(),
|
||||
break_message: display_config.break_message.clone(),
|
||||
break_title: if self.pomodoro_is_long_break {
|
||||
self.config.pomodoro_long_break_title.clone()
|
||||
} else {
|
||||
display_config.break_title.clone()
|
||||
},
|
||||
break_message: if self.pomodoro_is_long_break {
|
||||
self.config.pomodoro_long_break_message.clone()
|
||||
} else {
|
||||
display_config.break_message.clone()
|
||||
},
|
||||
break_progress,
|
||||
break_time_remaining: self.time_until_break_end,
|
||||
break_total_duration: self.break_total_duration,
|
||||
@@ -442,6 +638,27 @@ impl TimerManager {
|
||||
natural_break_occurred: self.natural_break_occurred,
|
||||
smart_breaks_enabled: display_config.smart_breaks_enabled,
|
||||
smart_break_threshold: display_config.smart_break_threshold,
|
||||
// F1: Microbreaks
|
||||
microbreak_enabled: self.config.microbreak_enabled,
|
||||
microbreak_active: self.microbreak_active,
|
||||
microbreak_time_remaining: self.microbreak_time_until_end,
|
||||
microbreak_total_duration: self.microbreak_total_duration,
|
||||
microbreak_countdown: self.microbreak_time_remaining,
|
||||
microbreak_frequency: self.config.microbreak_frequency,
|
||||
// F3: Pomodoro
|
||||
pomodoro_enabled: self.config.pomodoro_enabled,
|
||||
pomodoro_cycle_position: self.pomodoro_cycle_position,
|
||||
pomodoro_total_in_cycle: pomo_total,
|
||||
pomodoro_is_long_break: self.pomodoro_is_long_break,
|
||||
pomodoro_next_is_long: pomo_next_is_long,
|
||||
// F5: Screen dimming
|
||||
screen_dim_active,
|
||||
screen_dim_progress,
|
||||
// F2: Presentation mode
|
||||
presentation_mode_active: self.presentation_mode_active,
|
||||
deferred_break_pending: self.deferred_break_pending,
|
||||
// F10
|
||||
is_long_break: self.pomodoro_is_long_break,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,6 +669,14 @@ pub enum TickResult {
|
||||
BreakEnded,
|
||||
PreBreakWarning { seconds_until_break: u64 },
|
||||
NaturalBreakDetected { duration_seconds: u64 },
|
||||
BreakDeferred, // F2
|
||||
}
|
||||
|
||||
/// F1: Microbreak tick result
|
||||
pub enum MicrobreakTickResult {
|
||||
None,
|
||||
MicrobreakStarted,
|
||||
MicrobreakEnded,
|
||||
}
|
||||
|
||||
/// Result of checking idle state
|
||||
@@ -486,3 +711,101 @@ pub fn get_idle_seconds() -> u64 {
|
||||
pub fn get_idle_seconds() -> u64 {
|
||||
0
|
||||
}
|
||||
|
||||
/// F2: Check if the foreground window is a fullscreen application
|
||||
#[cfg(windows)]
|
||||
pub fn is_foreground_fullscreen() -> bool {
|
||||
use std::mem;
|
||||
use winapi::shared::windef::{HWND, RECT};
|
||||
use winapi::um::winuser::{
|
||||
GetForegroundWindow, GetWindowRect, MonitorFromWindow, GetMonitorInfoW,
|
||||
MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let hwnd: HWND = GetForegroundWindow();
|
||||
if hwnd.is_null() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the monitor this window is on
|
||||
let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
if monitor.is_null() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut mi: MONITORINFO = mem::zeroed();
|
||||
mi.cbSize = mem::size_of::<MONITORINFO>() as u32;
|
||||
if GetMonitorInfoW(monitor, &mut mi) == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut wr: RECT = mem::zeroed();
|
||||
if GetWindowRect(hwnd, &mut wr) == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if window rect covers the monitor rect
|
||||
let mr = mi.rcMonitor;
|
||||
wr.left <= mr.left && wr.top <= mr.top && wr.right >= mr.right && wr.bottom >= mr.bottom
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn is_foreground_fullscreen() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// F9: Get all monitor rects for multi-monitor break enforcement
|
||||
#[cfg(windows)]
|
||||
pub fn get_all_monitors() -> Vec<MonitorInfo> {
|
||||
use winapi::shared::windef::{HMONITOR, HDC, LPRECT};
|
||||
use winapi::um::winuser::{EnumDisplayMonitors, GetMonitorInfoW, MONITORINFO};
|
||||
|
||||
unsafe extern "system" fn callback(
|
||||
monitor: HMONITOR,
|
||||
_hdc: HDC,
|
||||
_rect: LPRECT,
|
||||
data: isize,
|
||||
) -> i32 {
|
||||
let monitors = &mut *(data as *mut Vec<MonitorInfo>);
|
||||
let mut mi: MONITORINFO = std::mem::zeroed();
|
||||
mi.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
|
||||
if GetMonitorInfoW(monitor, &mut mi) != 0 {
|
||||
let r = mi.rcMonitor;
|
||||
monitors.push(MonitorInfo {
|
||||
x: r.left,
|
||||
y: r.top,
|
||||
width: (r.right - r.left) as u32,
|
||||
height: (r.bottom - r.top) as u32,
|
||||
is_primary: (mi.dwFlags & 1) != 0, // MONITORINFOF_PRIMARY = 1
|
||||
});
|
||||
}
|
||||
1 // continue enumeration
|
||||
}
|
||||
|
||||
let mut monitors: Vec<MonitorInfo> = Vec::new();
|
||||
unsafe {
|
||||
EnumDisplayMonitors(
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null(),
|
||||
Some(callback),
|
||||
&mut monitors as *mut Vec<MonitorInfo> as isize,
|
||||
);
|
||||
}
|
||||
monitors
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn get_all_monitors() -> Vec<MonitorInfo> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MonitorInfo {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user