This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user