Add pomodoro, microbreaks, breathing guide, screen dimming, presentation mode, goals, multi-monitor, and activity manager
Major feature release (v0.1.3) adding 15 new features to the break timer: Backend (Rust): - Pomodoro cycle tracking with configurable short/long break pattern - Microbreak scheduling (20-20-20 rule) with independent timer - Screen dimming events with gradual opacity progression - Presentation mode detection (fullscreen app deferral) - Smart break detection (natural idle breaks counting toward goals) - Daily goal tracking and streak milestone events - Multi-monitor break overlay support - Working hours enforcement with per-day schedules - Weekly summary and natural break stats queries - Config expanded to 71 validated fields Frontend (Svelte): - 6 new components: BreathingGuide, ActivityManager, BreakOverlay, MicrobreakOverlay, DimOverlay, Celebration - Breathing guide with 5 patterns and animated pulsing halo - Activity manager with favorites, custom activities, momentum scroll - Confetti celebrations on milestones and goal completion - Dashboard indicators (pomodoro/microbreak/goal) moved inside ring - Settings reorganized into 18 logical cards - Breathing pattern selector redesigned with timing descriptions - Break activities expanded from 40 to 71 curated exercises - Sound presets expanded from 4 to 8 - Stats view with weekly summary and natural break tracking Also: version bump to 0.1.3, CHANGELOG, README and CLAUDE.md updates
This commit is contained in:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -480,7 +480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "core-cooldown"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "core-cooldown"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -21,4 +21,4 @@ chrono = "0.4"
|
||||
anyhow = "1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi"] }
|
||||
winapi = { version = "0.3", features = ["winuser", "sysinfoapi", "windef", "shellapi", "winreg"] }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui-fr/nicegui/main/src-tauri/gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main", "mini", "break"],
|
||||
"windows": ["main", "mini", "break", "microbreak", "dim", "break-overlay-0", "break-overlay-1", "break-overlay-2", "break-overlay-3", "break-overlay-4", "break-overlay-5"],
|
||||
"permissions": [
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
|
||||
@@ -3,6 +3,16 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A custom break activity defined by the user.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomActivity {
|
||||
pub id: String,
|
||||
pub category: String,
|
||||
pub text: String,
|
||||
pub is_favorite: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// A single time range (e.g., 09:00 to 17:00)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimeRange {
|
||||
@@ -107,6 +117,54 @@ pub struct Config {
|
||||
pub mini_click_through: bool, // Mini mode is click-through until hovered
|
||||
pub mini_hover_threshold: f32, // Seconds to hover before enabling drag (1.0-10.0)
|
||||
|
||||
// F8: Auto-start on Windows login
|
||||
pub auto_start_on_login: bool,
|
||||
|
||||
// F6: Custom break activities
|
||||
pub custom_activities: Vec<CustomActivity>,
|
||||
pub disabled_builtin_activities: Vec<String>,
|
||||
pub favorite_builtin_activities: Vec<String>,
|
||||
pub favorite_weight: u32, // Multiplier for favorites in random pool (2-10)
|
||||
|
||||
// F4: Guided breathing animation
|
||||
pub breathing_guide_enabled: bool,
|
||||
pub breathing_pattern: String, // "box", "relaxing", "energizing", "calm", "deep"
|
||||
|
||||
// F10: Break streaks & gamification
|
||||
pub daily_goal_enabled: bool,
|
||||
pub daily_goal_breaks: u32, // 1-30
|
||||
pub milestone_celebrations: bool,
|
||||
pub streak_notifications: bool,
|
||||
|
||||
// F1: Microbreaks & 20-20-20
|
||||
pub microbreak_enabled: bool,
|
||||
pub microbreak_frequency: u32, // 5-60 min
|
||||
pub microbreak_duration: u32, // 10-60 sec
|
||||
pub microbreak_sound_enabled: bool,
|
||||
pub microbreak_show_activity: bool,
|
||||
pub microbreak_pause_during_break: bool,
|
||||
|
||||
// F3: Pomodoro Mode
|
||||
pub pomodoro_enabled: bool,
|
||||
pub pomodoro_short_breaks: u32, // 1-10 (short breaks before long)
|
||||
pub pomodoro_long_break_duration: u32, // 5-60 min
|
||||
pub pomodoro_long_break_title: String, // max 100 chars
|
||||
pub pomodoro_long_break_message: String, // max 500 chars
|
||||
pub pomodoro_reset_on_skip: bool,
|
||||
|
||||
// F5: Screen dimming pre-break nudge
|
||||
pub screen_dim_enabled: bool,
|
||||
pub screen_dim_seconds: u32, // 3-60 sec before break
|
||||
pub screen_dim_max_opacity: f32, // 0.1-0.7
|
||||
|
||||
// F2: Presentation mode / fullscreen detection
|
||||
pub presentation_mode_enabled: bool,
|
||||
pub presentation_mode_defer_microbreaks: bool,
|
||||
pub presentation_mode_notification: bool,
|
||||
|
||||
// F9: Multi-monitor break enforcement
|
||||
pub multi_monitor_break: bool,
|
||||
|
||||
// Window positions (persisted between launches)
|
||||
pub main_window_x: Option<i32>,
|
||||
pub main_window_y: Option<i32>,
|
||||
@@ -186,6 +244,54 @@ impl Default for Config {
|
||||
mini_click_through: true,
|
||||
mini_hover_threshold: 3.0,
|
||||
|
||||
// F8: Auto-start
|
||||
auto_start_on_login: false,
|
||||
|
||||
// F6: Custom activities
|
||||
custom_activities: Vec::new(),
|
||||
disabled_builtin_activities: Vec::new(),
|
||||
favorite_builtin_activities: Vec::new(),
|
||||
favorite_weight: 3,
|
||||
|
||||
// F4: Breathing guide
|
||||
breathing_guide_enabled: true,
|
||||
breathing_pattern: "box".to_string(),
|
||||
|
||||
// F10: Gamification
|
||||
daily_goal_enabled: true,
|
||||
daily_goal_breaks: 8,
|
||||
milestone_celebrations: true,
|
||||
streak_notifications: true,
|
||||
|
||||
// F1: Microbreaks
|
||||
microbreak_enabled: false,
|
||||
microbreak_frequency: 20,
|
||||
microbreak_duration: 20,
|
||||
microbreak_sound_enabled: true,
|
||||
microbreak_show_activity: true,
|
||||
microbreak_pause_during_break: true,
|
||||
|
||||
// F3: Pomodoro
|
||||
pomodoro_enabled: false,
|
||||
pomodoro_short_breaks: 3,
|
||||
pomodoro_long_break_duration: 15,
|
||||
pomodoro_long_break_title: "Long break".to_string(),
|
||||
pomodoro_long_break_message: "Great work! Take a longer rest.".to_string(),
|
||||
pomodoro_reset_on_skip: false,
|
||||
|
||||
// F5: Screen dimming
|
||||
screen_dim_enabled: false,
|
||||
screen_dim_seconds: 10,
|
||||
screen_dim_max_opacity: 0.3,
|
||||
|
||||
// F2: Presentation mode
|
||||
presentation_mode_enabled: true,
|
||||
presentation_mode_defer_microbreaks: true,
|
||||
presentation_mode_notification: true,
|
||||
|
||||
// F9: Multi-monitor
|
||||
multi_monitor_break: true,
|
||||
|
||||
// Window positions
|
||||
main_window_x: None,
|
||||
main_window_y: None,
|
||||
@@ -349,6 +455,44 @@ impl Config {
|
||||
// UI zoom: 50-200%
|
||||
self.ui_zoom = self.ui_zoom.clamp(50, 200);
|
||||
|
||||
// F6: Custom activities
|
||||
if self.custom_activities.len() > 100 {
|
||||
self.custom_activities.truncate(100);
|
||||
}
|
||||
for act in &mut self.custom_activities {
|
||||
if act.text.len() > 500 {
|
||||
act.text.truncate(500);
|
||||
}
|
||||
}
|
||||
self.favorite_weight = self.favorite_weight.clamp(2, 10);
|
||||
|
||||
// F4: Breathing pattern
|
||||
let valid_patterns = vec!["box", "relaxing", "energizing", "calm", "deep"];
|
||||
if !valid_patterns.contains(&self.breathing_pattern.as_str()) {
|
||||
self.breathing_pattern = "box".to_string();
|
||||
}
|
||||
|
||||
// F10: Daily goal
|
||||
self.daily_goal_breaks = self.daily_goal_breaks.clamp(1, 30);
|
||||
|
||||
// F1: Microbreaks
|
||||
self.microbreak_frequency = self.microbreak_frequency.clamp(5, 60);
|
||||
self.microbreak_duration = self.microbreak_duration.clamp(10, 60);
|
||||
|
||||
// F3: Pomodoro
|
||||
self.pomodoro_short_breaks = self.pomodoro_short_breaks.clamp(1, 10);
|
||||
self.pomodoro_long_break_duration = self.pomodoro_long_break_duration.clamp(5, 60);
|
||||
if self.pomodoro_long_break_title.len() > 100 {
|
||||
self.pomodoro_long_break_title.truncate(100);
|
||||
}
|
||||
if self.pomodoro_long_break_message.len() > 500 {
|
||||
self.pomodoro_long_break_message.truncate(500);
|
||||
}
|
||||
|
||||
// F5: Screen dimming
|
||||
self.screen_dim_seconds = self.screen_dim_seconds.clamp(3, 60);
|
||||
self.screen_dim_max_opacity = self.screen_dim_max_opacity.clamp(0.1, 0.7);
|
||||
|
||||
// Validate color hex strings
|
||||
if !Self::is_valid_hex_color(&self.accent_color) {
|
||||
self.accent_color = "#ff4d00".to_string();
|
||||
|
||||
@@ -15,7 +15,7 @@ use tauri::{
|
||||
AppHandle, Emitter, Manager, State,
|
||||
};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use timer::{AppView, TickResult, TimerManager, TimerSnapshot};
|
||||
use timer::{AppView, MicrobreakTickResult, TickResult, TimerManager, TimerSnapshot};
|
||||
|
||||
pub struct AppState {
|
||||
pub timer: Arc<Mutex<TimerManager>>,
|
||||
@@ -127,7 +127,13 @@ fn set_view(state: State<AppState>, view: AppView) {
|
||||
#[tauri::command]
|
||||
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
|
||||
let s = state.stats.lock().unwrap();
|
||||
s.snapshot()
|
||||
let timer = state.timer.lock().unwrap();
|
||||
let daily_goal = if timer.config.daily_goal_enabled {
|
||||
timer.config.daily_goal_breaks
|
||||
} else {
|
||||
0
|
||||
};
|
||||
s.snapshot(daily_goal)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -136,6 +142,126 @@ fn get_daily_history(state: State<AppState>, days: u32) -> Vec<stats::DayRecord>
|
||||
s.recent_days(days)
|
||||
}
|
||||
|
||||
// F7: Weekly summary command
|
||||
#[tauri::command]
|
||||
fn get_weekly_summary(state: State<AppState>, weeks: u32) -> Vec<stats::WeekSummary> {
|
||||
let s = state.stats.lock().unwrap();
|
||||
s.weekly_summary(weeks)
|
||||
}
|
||||
|
||||
// F8: Auto-start on Windows login
|
||||
#[tauri::command]
|
||||
fn set_auto_start(enabled: bool) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use winapi::um::winreg::{RegOpenKeyExW, RegSetValueExW, RegDeleteValueW, HKEY_CURRENT_USER};
|
||||
use winapi::um::winnt::{KEY_SET_VALUE, REG_SZ};
|
||||
|
||||
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
let mut hkey = std::ptr::null_mut();
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
sub_key.as_ptr(),
|
||||
0,
|
||||
KEY_SET_VALUE,
|
||||
&mut hkey,
|
||||
);
|
||||
if result != 0 {
|
||||
return Err("Failed to open registry key".to_string());
|
||||
}
|
||||
|
||||
if enabled {
|
||||
let exe_path = std::env::current_exe()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let path_str = exe_path.to_string_lossy();
|
||||
let value_data: Vec<u16> = OsStr::new(&*path_str)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let res = RegSetValueExW(
|
||||
hkey,
|
||||
value_name.as_ptr(),
|
||||
0,
|
||||
REG_SZ,
|
||||
value_data.as_ptr() as *const u8,
|
||||
(value_data.len() * 2) as u32,
|
||||
);
|
||||
winapi::um::winreg::RegCloseKey(hkey);
|
||||
if res != 0 {
|
||||
return Err("Failed to set registry value".to_string());
|
||||
}
|
||||
} else {
|
||||
let _res = RegDeleteValueW(hkey, value_name.as_ptr());
|
||||
winapi::um::winreg::RegCloseKey(hkey);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Err("Auto-start is only supported on Windows".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_auto_start_status() -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER};
|
||||
use winapi::um::winnt::KEY_READ;
|
||||
|
||||
let sub_key: Vec<u16> = OsStr::new("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
let value_name: Vec<u16> = OsStr::new("CoreCooldown")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
let mut hkey = std::ptr::null_mut();
|
||||
let result = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
sub_key.as_ptr(),
|
||||
0,
|
||||
KEY_READ,
|
||||
&mut hkey,
|
||||
);
|
||||
if result != 0 {
|
||||
return false;
|
||||
}
|
||||
let res = RegQueryValueExW(
|
||||
hkey,
|
||||
value_name.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
winapi::um::winreg::RegCloseKey(hkey);
|
||||
res == 0
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cursor / Window Position Commands ────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -197,12 +323,14 @@ fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
|
||||
}
|
||||
|
||||
/// Render a 32x32 RGBA icon with a progress arc using the given accent/break colors.
|
||||
/// F10: Optionally renders a green checkmark when daily goal is met.
|
||||
fn render_tray_icon(
|
||||
progress: f64,
|
||||
is_break: bool,
|
||||
is_paused: bool,
|
||||
accent: (u8, u8, u8),
|
||||
break_color: (u8, u8, u8),
|
||||
goal_met: bool,
|
||||
) -> Vec<u8> {
|
||||
let size: usize = 32;
|
||||
let mut rgba = vec![0u8; size * size * 4];
|
||||
@@ -241,6 +369,28 @@ fn render_tray_icon(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F10: Draw a small green dot in the bottom-right corner when goal is met
|
||||
if goal_met {
|
||||
let dot_cx = 25.0_f64;
|
||||
let dot_cy = 25.0_f64;
|
||||
let dot_r = 4.0_f64;
|
||||
for y in 20..32 {
|
||||
for x in 20..32 {
|
||||
let dx = x as f64 - dot_cx;
|
||||
let dy = y as f64 - dot_cy;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist <= dot_r {
|
||||
let idx = (y * size + x) * 4;
|
||||
rgba[idx] = 63; // green
|
||||
rgba[idx + 1] = 185;
|
||||
rgba[idx + 2] = 80;
|
||||
rgba[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rgba
|
||||
}
|
||||
|
||||
@@ -249,13 +399,18 @@ fn update_tray(
|
||||
snapshot: &TimerSnapshot,
|
||||
accent: (u8, u8, u8),
|
||||
break_color: (u8, u8, u8),
|
||||
goal_met: bool,
|
||||
) {
|
||||
// Update tooltip
|
||||
let tooltip = match snapshot.state {
|
||||
timer::TimerState::Running => {
|
||||
let m = snapshot.time_remaining / 60;
|
||||
let s = snapshot.time_remaining % 60;
|
||||
format!("Core Cooldown — {:02}:{:02} until break", m, s)
|
||||
if snapshot.deferred_break_pending {
|
||||
"Core Cooldown — Break deferred (fullscreen)".to_string()
|
||||
} else {
|
||||
let m = snapshot.time_remaining / 60;
|
||||
let s = snapshot.time_remaining % 60;
|
||||
format!("Core Cooldown — {:02}:{:02} until break", m, s)
|
||||
}
|
||||
}
|
||||
timer::TimerState::Paused => {
|
||||
if snapshot.idle_paused {
|
||||
@@ -279,7 +434,7 @@ fn update_tray(
|
||||
timer::TimerState::BreakActive => (snapshot.break_progress, true, false),
|
||||
};
|
||||
|
||||
let icon_data = render_tray_icon(progress, is_break, is_paused, accent, break_color);
|
||||
let icon_data = render_tray_icon(progress, is_break, is_paused, accent, break_color, goal_met);
|
||||
let icon = Image::new_owned(icon_data, 32, 32);
|
||||
let _ = tray.set_icon(Some(icon));
|
||||
}
|
||||
@@ -369,41 +524,133 @@ pub fn run() {
|
||||
let handle = app.handle().clone();
|
||||
let timer_ref = app.state::<AppState>().timer.clone();
|
||||
let stats_ref = app.state::<AppState>().stats.clone();
|
||||
let data_dir_clone = data_dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut dim_window_open = false;
|
||||
let mut microbreak_window_open = false;
|
||||
let mut break_deferred_notified = false;
|
||||
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
|
||||
let (tick_result, snapshot, accent_hex, break_hex) = {
|
||||
let (tick_result, mb_result, snapshot, accent_hex, break_hex, daily_goal, daily_goal_enabled, goal_met) = {
|
||||
let mut timer = timer_ref.lock().unwrap();
|
||||
let result = timer.tick();
|
||||
let mb = timer.tick_microbreak();
|
||||
let snap = timer.snapshot();
|
||||
let ac = timer.config.accent_color.clone();
|
||||
let bc = timer.config.break_color.clone();
|
||||
(result, snap, ac, bc)
|
||||
let dg = timer.config.daily_goal_breaks;
|
||||
let dge = timer.config.daily_goal_enabled;
|
||||
// Check goal status
|
||||
let s = stats_ref.lock().unwrap();
|
||||
let goal_target = if dge { dg } else { 0 };
|
||||
let ss = s.snapshot(goal_target);
|
||||
(result, mb, snap, ac, bc, dg, dge, ss.daily_goal_met)
|
||||
};
|
||||
|
||||
// Update tray icon and tooltip with configured colors
|
||||
let accent = parse_hex_color(&accent_hex, (255, 77, 0));
|
||||
let break_c = parse_hex_color(&break_hex, (124, 106, 239));
|
||||
update_tray(&tray, &snapshot, accent, break_c);
|
||||
update_tray(&tray, &snapshot, accent, break_c, goal_met);
|
||||
|
||||
// Emit tick event with full snapshot
|
||||
let _ = handle.emit("timer-tick", &snapshot);
|
||||
|
||||
// F5: Screen dim window management
|
||||
if snapshot.screen_dim_active && !dim_window_open {
|
||||
open_dim_overlay(&handle, &data_dir_clone);
|
||||
dim_window_open = true;
|
||||
} else if !snapshot.screen_dim_active && dim_window_open {
|
||||
close_dim_overlay(&handle);
|
||||
dim_window_open = false;
|
||||
}
|
||||
if snapshot.screen_dim_active {
|
||||
let max_opacity = {
|
||||
let t = timer_ref.lock().unwrap();
|
||||
t.config.screen_dim_max_opacity
|
||||
};
|
||||
let _ = handle.emit("screen-dim-update", &serde_json::json!({
|
||||
"progress": snapshot.screen_dim_progress,
|
||||
"maxOpacity": max_opacity
|
||||
}));
|
||||
}
|
||||
|
||||
// F1: Microbreak window management
|
||||
match mb_result {
|
||||
MicrobreakTickResult::MicrobreakStarted => {
|
||||
open_microbreak_window(&handle, &data_dir_clone);
|
||||
microbreak_window_open = true;
|
||||
let _ = handle.emit("microbreak-started", &());
|
||||
}
|
||||
MicrobreakTickResult::MicrobreakEnded => {
|
||||
close_microbreak_window(&handle);
|
||||
microbreak_window_open = false;
|
||||
let _ = handle.emit("microbreak-ended", &());
|
||||
}
|
||||
MicrobreakTickResult::None => {}
|
||||
}
|
||||
|
||||
// Emit specific events for state transitions
|
||||
match tick_result {
|
||||
TickResult::BreakStarted(payload) => {
|
||||
// Close dim overlay if it was open
|
||||
if dim_window_open {
|
||||
close_dim_overlay(&handle);
|
||||
dim_window_open = false;
|
||||
}
|
||||
// Close microbreak if active
|
||||
if microbreak_window_open {
|
||||
close_microbreak_window(&handle);
|
||||
microbreak_window_open = false;
|
||||
}
|
||||
handle_break_start(&handle, payload.fullscreen_mode);
|
||||
// F9: Multi-monitor overlays
|
||||
{
|
||||
let timer = timer_ref.lock().unwrap();
|
||||
if timer.config.fullscreen_mode && timer.config.multi_monitor_break {
|
||||
open_multi_monitor_overlays(&handle, &data_dir_clone);
|
||||
}
|
||||
}
|
||||
let _ = handle.emit("break-started", &payload);
|
||||
break_deferred_notified = false;
|
||||
}
|
||||
TickResult::BreakEnded => {
|
||||
// Restore normal window state and close break window
|
||||
handle_break_end(&handle);
|
||||
// F9: Close multi-monitor overlays
|
||||
close_multi_monitor_overlays(&handle);
|
||||
// Record completed break in stats
|
||||
{
|
||||
let break_result = {
|
||||
let timer = timer_ref.lock().unwrap();
|
||||
let goal = if daily_goal_enabled { daily_goal } else { 0 };
|
||||
let mut s = stats_ref.lock().unwrap();
|
||||
s.record_break_completed(timer.break_total_duration);
|
||||
s.record_break_completed(timer.break_total_duration, goal)
|
||||
};
|
||||
// F10: Emit milestone/goal events
|
||||
if let Some(streak) = break_result.milestone_reached {
|
||||
let _ = handle.emit("milestone-reached", &streak);
|
||||
let timer = timer_ref.lock().unwrap();
|
||||
if timer.config.streak_notifications {
|
||||
let _ = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Streak milestone!")
|
||||
.body(&format!("{}-day streak! Keep it up!", streak))
|
||||
.show();
|
||||
}
|
||||
}
|
||||
if break_result.daily_goal_just_met {
|
||||
let _ = handle.emit("daily-goal-met", &());
|
||||
let timer = timer_ref.lock().unwrap();
|
||||
if timer.config.streak_notifications {
|
||||
let _ = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Daily goal reached!")
|
||||
.body("You've hit your break goal for today.")
|
||||
.show();
|
||||
}
|
||||
}
|
||||
let _ = handle
|
||||
.notification()
|
||||
@@ -464,6 +711,22 @@ pub fn run() {
|
||||
.show();
|
||||
let _ = handle.emit("natural-break-detected", &duration_seconds);
|
||||
}
|
||||
TickResult::BreakDeferred => {
|
||||
// F2: Notify once when break gets deferred
|
||||
if !break_deferred_notified {
|
||||
break_deferred_notified = true;
|
||||
let timer = timer_ref.lock().unwrap();
|
||||
if timer.config.presentation_mode_notification {
|
||||
let _ = handle
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Break deferred")
|
||||
.body("Fullscreen app detected — break will start when you exit.")
|
||||
.show();
|
||||
}
|
||||
let _ = handle.emit("break-deferred", &());
|
||||
}
|
||||
}
|
||||
TickResult::None => {}
|
||||
}
|
||||
}
|
||||
@@ -493,6 +756,9 @@ pub fn run() {
|
||||
set_view,
|
||||
get_stats,
|
||||
get_daily_history,
|
||||
get_weekly_summary,
|
||||
set_auto_start,
|
||||
get_auto_start_status,
|
||||
get_cursor_position,
|
||||
save_window_position,
|
||||
])
|
||||
@@ -688,3 +954,114 @@ fn toggle_mini_window(app: &AppHandle) {
|
||||
let _ = builder.build();
|
||||
}
|
||||
}
|
||||
|
||||
// ── F1: Microbreak Window ──────────────────────────────────────────────────
|
||||
|
||||
fn open_microbreak_window(app: &AppHandle, data_dir: &std::path::Path) {
|
||||
if app.get_webview_window("microbreak").is_some() {
|
||||
return; // already open
|
||||
}
|
||||
|
||||
let _ = tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"microbreak",
|
||||
tauri::WebviewUrl::App("index.html?microbreak=1".into()),
|
||||
)
|
||||
.title("Eye Break")
|
||||
.inner_size(400.0, 180.0)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.center()
|
||||
.data_directory(data_dir.to_path_buf())
|
||||
.build();
|
||||
}
|
||||
|
||||
fn close_microbreak_window(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window("microbreak") {
|
||||
let _ = win.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── F5: Dim Overlay Window ──────────────────────────────────────────────────
|
||||
|
||||
fn open_dim_overlay(app: &AppHandle, data_dir: &std::path::Path) {
|
||||
if app.get_webview_window("dim").is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let builder = tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"dim",
|
||||
tauri::WebviewUrl::App("index.html?dim=1".into()),
|
||||
)
|
||||
.title("")
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.maximized(true)
|
||||
.data_directory(data_dir.to_path_buf());
|
||||
|
||||
if let Ok(win) = builder.build() {
|
||||
let _ = win.set_ignore_cursor_events(true);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_dim_overlay(app: &AppHandle) {
|
||||
if let Some(win) = app.get_webview_window("dim") {
|
||||
let _ = win.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── F9: Multi-Monitor Break Overlays ────────────────────────────────────────
|
||||
|
||||
fn open_multi_monitor_overlays(app: &AppHandle, data_dir: &std::path::Path) {
|
||||
let monitors = timer::get_all_monitors();
|
||||
|
||||
for (i, mon) in monitors.iter().enumerate() {
|
||||
if mon.is_primary {
|
||||
continue; // Primary is handled by the main break window
|
||||
}
|
||||
if i > 5 {
|
||||
break; // Cap at 6 monitors
|
||||
}
|
||||
|
||||
let label = format!("break-overlay-{}", i);
|
||||
if app.get_webview_window(&label).is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
&label,
|
||||
tauri::WebviewUrl::App("index.html?breakoverlay=1".into()),
|
||||
)
|
||||
.title("")
|
||||
.position(mon.x as f64, mon.y as f64)
|
||||
.inner_size(mon.width as f64, mon.height as f64)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.shadow(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.data_directory(data_dir.to_path_buf())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
fn close_multi_monitor_overlays(app: &AppHandle) {
|
||||
// Close any window with label starting with "break-overlay-"
|
||||
for i in 0..6 {
|
||||
let label = format!("break-overlay-{}", i);
|
||||
if let Some(win) = app.get_webview_window(&label) {
|
||||
let _ = win.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,31 @@ pub struct StatsSnapshot {
|
||||
pub compliance_rate: f64,
|
||||
pub current_streak: u32,
|
||||
pub best_streak: u32,
|
||||
// F10: Daily goal
|
||||
pub daily_goal_progress: u32,
|
||||
pub daily_goal_met: bool,
|
||||
}
|
||||
|
||||
/// F10: Result of recording a completed break
|
||||
pub struct BreakCompletedResult {
|
||||
pub milestone_reached: Option<u32>,
|
||||
pub daily_goal_just_met: bool,
|
||||
}
|
||||
|
||||
/// F7: Weekly summary for reports
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WeekSummary {
|
||||
pub week_start: String,
|
||||
pub total_completed: u32,
|
||||
pub total_skipped: u32,
|
||||
pub total_break_time_secs: u64,
|
||||
pub compliance_rate: f64,
|
||||
pub avg_daily_completed: f64,
|
||||
}
|
||||
|
||||
const MILESTONES: &[u32] = &[3, 5, 7, 14, 21, 30, 50, 100, 365];
|
||||
|
||||
impl Stats {
|
||||
/// Portable: stats file lives next to the exe
|
||||
fn stats_path() -> Option<PathBuf> {
|
||||
@@ -91,12 +114,23 @@ impl Stats {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn record_break_completed(&mut self, duration_secs: u64) {
|
||||
/// Record a completed break. Returns milestone/goal info for gamification.
|
||||
pub fn record_break_completed(&mut self, duration_secs: u64, daily_goal: u32) -> BreakCompletedResult {
|
||||
let day = self.today_mut();
|
||||
let was_below_goal = day.breaks_completed < daily_goal;
|
||||
day.breaks_completed += 1;
|
||||
day.total_break_time_secs += duration_secs;
|
||||
let now_at_goal = day.breaks_completed >= daily_goal;
|
||||
self.update_streak();
|
||||
self.save();
|
||||
|
||||
let milestone = self.check_milestone();
|
||||
let daily_goal_just_met = was_below_goal && now_at_goal && daily_goal > 0;
|
||||
|
||||
BreakCompletedResult {
|
||||
milestone_reached: milestone,
|
||||
daily_goal_just_met,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_break_skipped(&mut self) {
|
||||
@@ -148,7 +182,17 @@ impl Stats {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> StatsSnapshot {
|
||||
/// F10: Check if current streak exactly matches a milestone
|
||||
fn check_milestone(&self) -> Option<u32> {
|
||||
let streak = self.data.current_streak;
|
||||
if MILESTONES.contains(&streak) {
|
||||
Some(streak)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self, daily_goal: u32) -> StatsSnapshot {
|
||||
let key = Self::today_key();
|
||||
let today = self.data.days.get(&key);
|
||||
|
||||
@@ -176,6 +220,8 @@ impl Stats {
|
||||
compliance_rate: compliance,
|
||||
current_streak: self.data.current_streak,
|
||||
best_streak: self.data.best_streak,
|
||||
daily_goal_progress: completed,
|
||||
daily_goal_met: daily_goal > 0 && completed >= daily_goal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,4 +242,47 @@ impl Stats {
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
/// F7: Get weekly summaries for the past N weeks
|
||||
pub fn weekly_summary(&self, weeks: u32) -> Vec<WeekSummary> {
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let mut summaries = Vec::new();
|
||||
|
||||
for w in 0..weeks {
|
||||
let week_end = today - chrono::Duration::days((w * 7) as i64);
|
||||
let week_start = week_end - chrono::Duration::days(6);
|
||||
|
||||
let mut total_completed = 0u32;
|
||||
let mut total_skipped = 0u32;
|
||||
let mut total_break_time = 0u64;
|
||||
|
||||
for d in 0..7 {
|
||||
let day = week_start + chrono::Duration::days(d);
|
||||
let key = day.format("%Y-%m-%d").to_string();
|
||||
if let Some(record) = self.data.days.get(&key) {
|
||||
total_completed += record.breaks_completed;
|
||||
total_skipped += record.breaks_skipped;
|
||||
total_break_time += record.total_break_time_secs;
|
||||
}
|
||||
}
|
||||
|
||||
let total = total_completed + total_skipped;
|
||||
let compliance = if total > 0 {
|
||||
total_completed as f64 / total as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
summaries.push(WeekSummary {
|
||||
week_start: week_start.format("%Y-%m-%d").to_string(),
|
||||
total_completed,
|
||||
total_skipped,
|
||||
total_break_time_secs: total_break_time,
|
||||
compliance_rate: compliance,
|
||||
avg_daily_completed: total_completed as f64 / 7.0,
|
||||
});
|
||||
}
|
||||
|
||||
summaries
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "Core Cooldown",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"identifier": "com.corecooldown.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
Reference in New Issue
Block a user