This commit is contained in:
2026-02-07 01:12:32 +02:00
commit 27b1b8ae3d
47 changed files with 15106 additions and 0 deletions

688
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,688 @@
mod config;
mod stats;
mod timer;
use config::Config;
use stats::Stats;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItemBuilder},
tray::{TrayIcon, TrayIconBuilder},
AppHandle, Emitter, Manager, State,
};
use tauri_plugin_notification::NotificationExt;
use timer::{AppView, TickResult, TimerManager, TimerSnapshot};
pub struct AppState {
pub timer: Arc<Mutex<TimerManager>>,
pub stats: Arc<Mutex<Stats>>,
}
// ── Tauri Commands ──────────────────────────────────────────────────────────
#[tauri::command]
fn get_config(state: State<AppState>) -> Config {
let timer = state.timer.lock().unwrap();
timer.pending_config.clone()
}
#[tauri::command]
fn save_config(state: State<AppState>, config: Config) -> Result<(), String> {
let mut timer = state.timer.lock().unwrap();
timer.update_config(config);
timer.save_config()
}
#[tauri::command]
fn update_pending_config(app: AppHandle, state: State<AppState>, config: Config) {
let mut timer = state.timer.lock().unwrap();
timer.update_config(config);
// Notify all windows (break, mini) so they can pick up live config changes
let _ = app.emit("config-changed", &());
}
#[tauri::command]
fn reset_config(state: State<AppState>) -> Config {
let mut timer = state.timer.lock().unwrap();
timer.reset_config();
timer.pending_config.clone()
}
#[tauri::command]
fn toggle_timer(state: State<AppState>) -> TimerSnapshot {
let mut timer = state.timer.lock().unwrap();
timer.toggle_timer();
timer.snapshot()
}
#[tauri::command]
fn start_break_now(app: AppHandle, state: State<AppState>) -> TimerSnapshot {
let (snapshot, fullscreen_mode) = {
let mut timer = state.timer.lock().unwrap();
let payload = timer.start_break_now();
let fm = payload.as_ref().map(|p| p.fullscreen_mode);
(timer.snapshot(), fm)
};
// Window creation must happen after dropping the mutex
if let Some(fm) = fullscreen_mode {
handle_break_start(&app, fm);
}
snapshot
}
#[tauri::command]
fn cancel_break(app: AppHandle, state: State<AppState>) -> TimerSnapshot {
let (snapshot, should_end) = {
let mut timer = state.timer.lock().unwrap();
let was_break = timer.state == timer::TimerState::BreakActive;
timer.cancel_break();
let ended = was_break && timer.state != timer::TimerState::BreakActive;
if ended {
let mut s = state.stats.lock().unwrap();
s.record_break_skipped();
}
(timer.snapshot(), ended)
};
if should_end {
handle_break_end(&app);
}
snapshot
}
#[tauri::command]
fn snooze(app: AppHandle, state: State<AppState>) -> TimerSnapshot {
let (snapshot, should_end) = {
let mut timer = state.timer.lock().unwrap();
let was_break = timer.state == timer::TimerState::BreakActive;
timer.snooze();
let ended = was_break && timer.state != timer::TimerState::BreakActive;
if ended {
let mut s = state.stats.lock().unwrap();
s.record_break_snoozed();
}
(timer.snapshot(), ended)
};
if should_end {
handle_break_end(&app);
}
snapshot
}
#[tauri::command]
fn get_timer_state(state: State<AppState>) -> TimerSnapshot {
let timer = state.timer.lock().unwrap();
timer.snapshot()
}
#[tauri::command]
fn set_view(state: State<AppState>, view: AppView) {
let mut timer = state.timer.lock().unwrap();
timer.set_view(view);
}
#[tauri::command]
fn get_stats(state: State<AppState>) -> stats::StatsSnapshot {
let s = state.stats.lock().unwrap();
s.snapshot()
}
#[tauri::command]
fn get_daily_history(state: State<AppState>, days: u32) -> Vec<stats::DayRecord> {
let s = state.stats.lock().unwrap();
s.recent_days(days)
}
// ── Cursor / Window Position Commands ────────────────────────────────────
#[tauri::command]
fn get_cursor_position() -> (i32, i32) {
#[cfg(windows)]
{
use winapi::shared::windef::POINT;
use winapi::um::winuser::GetCursorPos;
let mut point = POINT { x: 0, y: 0 };
unsafe {
GetCursorPos(&mut point);
}
(point.x, point.y)
}
#[cfg(not(windows))]
{
(0, 0)
}
}
#[tauri::command]
fn save_window_position(
state: State<AppState>,
label: String,
x: i32,
y: i32,
width: u32,
height: u32,
) {
let mut timer = state.timer.lock().unwrap();
match label.as_str() {
"main" => {
timer.pending_config.main_window_x = Some(x);
timer.pending_config.main_window_y = Some(y);
timer.pending_config.main_window_width = Some(width);
timer.pending_config.main_window_height = Some(height);
}
"mini" => {
timer.pending_config.mini_window_x = Some(x);
timer.pending_config.mini_window_y = Some(y);
}
_ => {}
}
let _ = timer.save_config();
}
// ── Dynamic Tray Icon Rendering ────────────────────────────────────────────
/// Parse a "#RRGGBB" hex color string into (r, g, b). Falls back to fallback on invalid input.
fn parse_hex_color(hex: &str, fallback: (u8, u8, u8)) -> (u8, u8, u8) {
if hex.len() == 7 && hex.starts_with('#') {
let r = u8::from_str_radix(&hex[1..3], 16).unwrap_or(fallback.0);
let g = u8::from_str_radix(&hex[3..5], 16).unwrap_or(fallback.1);
let b = u8::from_str_radix(&hex[5..7], 16).unwrap_or(fallback.2);
(r, g, b)
} else {
fallback
}
}
/// Render a 32x32 RGBA icon with a progress arc using the given accent/break colors.
fn render_tray_icon(
progress: f64,
is_break: bool,
is_paused: bool,
accent: (u8, u8, u8),
break_color: (u8, u8, u8),
) -> Vec<u8> {
let size: usize = 32;
let mut rgba = vec![0u8; size * size * 4];
let center = size as f64 / 2.0;
let outer_r = center - 1.0;
let inner_r = outer_r - 4.0;
let arc_color = if is_break { break_color } else { accent };
for y in 0..size {
for x in 0..size {
let dx = x as f64 - center;
let dy = y as f64 - center;
let dist = (dx * dx + dy * dy).sqrt();
let idx = (y * size + x) * 4;
if dist >= inner_r && dist <= outer_r {
// Determine angle (0 at top, clockwise)
let angle = (dx.atan2(-dy) + std::f64::consts::PI) / (2.0 * std::f64::consts::PI);
let in_arc = angle <= progress;
if in_arc {
rgba[idx] = arc_color.0;
rgba[idx + 1] = arc_color.1;
rgba[idx + 2] = arc_color.2;
rgba[idx + 3] = 255;
} else {
// Background ring
rgba[idx] = 60;
rgba[idx + 1] = 60;
rgba[idx + 2] = 60;
rgba[idx + 3] = if is_paused { 100 } else { 180 };
}
}
}
}
rgba
}
fn update_tray(
tray: &TrayIcon,
snapshot: &TimerSnapshot,
accent: (u8, u8, u8),
break_color: (u8, u8, u8),
) {
// 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)
}
timer::TimerState::Paused => {
if snapshot.idle_paused {
"Core Cooldown - Paused (idle)".to_string()
} else {
"Core Cooldown - Paused".to_string()
}
}
timer::TimerState::BreakActive => {
let m = snapshot.break_time_remaining / 60;
let s = snapshot.break_time_remaining % 60;
format!("Core Cooldown - Break {:02}:{:02}", m, s)
}
};
let _ = tray.set_tooltip(Some(&tooltip));
// Update icon
let (progress, is_break, is_paused) = match snapshot.state {
timer::TimerState::Running => (snapshot.progress, false, false),
timer::TimerState::Paused => (snapshot.progress, false, true),
timer::TimerState::BreakActive => (snapshot.break_progress, true, false),
};
let icon_data = render_tray_icon(progress, is_break, is_paused, accent, break_color);
let icon = Image::new_owned(icon_data, 32, 32);
let _ = tray.set_icon(Some(icon));
}
// ── App Builder ─────────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.setup(|app| {
// Portable data directory for WebView2 data (next to the exe)
let data_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("data")))
.unwrap_or_else(|| std::path::PathBuf::from("data"));
// Create main window (was previously in tauri.conf.json)
tauri::WebviewWindowBuilder::new(
app,
"main",
tauri::WebviewUrl::App("index.html".into()),
)
.title("Core Cooldown")
.inner_size(480.0, 700.0)
.decorations(false)
.transparent(true)
.shadow(true)
.data_directory(data_dir.clone())
.build()?;
// Create break window (hidden, was previously in tauri.conf.json)
tauri::WebviewWindowBuilder::new(
app,
"break",
tauri::WebviewUrl::App("index.html?break=1".into()),
)
.title("Core Cooldown - Break")
.inner_size(900.0, 540.0)
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.visible(false)
.center()
.data_directory(data_dir.clone())
.build()?;
let timer_manager = TimerManager::new();
let loaded_stats = Stats::load_or_default();
let state = AppState {
timer: Arc::new(Mutex::new(timer_manager)),
stats: Arc::new(Mutex::new(loaded_stats)),
};
app.manage(state);
// Restore saved window position
{
let st = app.state::<AppState>();
let timer = st.timer.lock().unwrap();
let cfg = &timer.pending_config;
if let (Some(x), Some(y)) = (cfg.main_window_x, cfg.main_window_y) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.set_position(tauri::Position::Physical(
tauri::PhysicalPosition::new(x, y),
));
}
}
if let (Some(w), Some(h)) = (cfg.main_window_width, cfg.main_window_height) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.set_size(tauri::Size::Physical(tauri::PhysicalSize::new(w, h)));
}
}
}
// Set up system tray
let tray = setup_tray(app.handle())?;
// Set up global shortcuts
setup_shortcuts(app.handle());
// Start the timer tick thread
let handle = app.handle().clone();
let timer_ref = app.state::<AppState>().timer.clone();
let stats_ref = app.state::<AppState>().stats.clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(1));
let (tick_result, snapshot, accent_hex, break_hex) = {
let mut timer = timer_ref.lock().unwrap();
let result = timer.tick();
let snap = timer.snapshot();
let ac = timer.config.accent_color.clone();
let bc = timer.config.break_color.clone();
(result, snap, ac, bc)
};
// 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);
// Emit tick event with full snapshot
let _ = handle.emit("timer-tick", &snapshot);
// Emit specific events for state transitions
match tick_result {
TickResult::BreakStarted(payload) => {
handle_break_start(&handle, payload.fullscreen_mode);
let _ = handle.emit("break-started", &payload);
}
TickResult::BreakEnded => {
// Restore normal window state and close break window
handle_break_end(&handle);
// Record completed break in stats
{
let timer = timer_ref.lock().unwrap();
let mut s = stats_ref.lock().unwrap();
s.record_break_completed(timer.break_total_duration);
}
let _ = handle
.notification()
.builder()
.title("Break complete")
.body("Great job! Back to work.")
.show();
let _ = handle.emit("break-ended", &());
}
TickResult::PreBreakWarning {
seconds_until_break,
} => {
let secs = seconds_until_break;
let msg = if secs >= 60 {
format!(
"Break in {} minute{}",
secs / 60,
if secs / 60 == 1 { "" } else { "s" }
)
} else {
format!("Break in {} seconds", secs)
};
let _ = handle
.notification()
.builder()
.title("Core Cooldown")
.body(&msg)
.show();
let _ = handle.emit("prebreak-warning", &secs);
}
TickResult::NaturalBreakDetected { duration_seconds } => {
// Record natural break in stats if enabled
{
let timer = timer_ref.lock().unwrap();
if timer.config.smart_break_count_stats {
let mut s = stats_ref.lock().unwrap();
s.record_natural_break(duration_seconds);
}
}
let mins = duration_seconds / 60;
let msg = if mins >= 1 {
format!(
"You've been away for {} minute{}. Break timer has been reset.",
mins,
if mins == 1 { "" } else { "s" }
)
} else {
format!(
"You've been away for {} seconds. Break timer has been reset.",
duration_seconds
)
};
let _ = handle
.notification()
.builder()
.title("Natural break detected")
.body(&msg)
.show();
let _ = handle.emit("natural-break-detected", &duration_seconds);
}
TickResult::None => {}
}
}
});
Ok(())
})
// Handle window close events - only exit when the main window is closed
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
if window.label() == "main" {
window.app_handle().exit(0);
}
// Mini and break windows just close normally without killing the app
}
})
.invoke_handler(tauri::generate_handler![
get_config,
save_config,
update_pending_config,
reset_config,
toggle_timer,
start_break_now,
cancel_break,
snooze,
get_timer_state,
set_view,
get_stats,
get_daily_history,
get_cursor_position,
save_window_position,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn setup_tray(app: &AppHandle) -> Result<TrayIcon, Box<dyn std::error::Error>> {
let show_i = MenuItemBuilder::with_id("show", "Show").build(app)?;
let pause_i = MenuItemBuilder::with_id("pause", "Pause/Resume").build(app)?;
let mini_i = MenuItemBuilder::with_id("mini", "Mini Mode").build(app)?;
let quit_i = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
let menu = MenuBuilder::new(app)
.item(&show_i)
.item(&pause_i)
.item(&mini_i)
.separator()
.item(&quit_i)
.build()?;
let tray = TrayIconBuilder::new()
.menu(&menu)
.tooltip("Core Cooldown")
.on_menu_event(move |app, event| match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
"pause" => {
let state: State<AppState> = app.state();
let mut timer = state.timer.lock().unwrap();
timer.toggle_timer();
}
"mini" => {
toggle_mini_window(app);
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::Click {
button: tauri::tray::MouseButton::Left,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(tray)
}
// ── Global Shortcuts ───────────────────────────────────────────────────────
fn setup_shortcuts(app: &AppHandle) {
use tauri_plugin_global_shortcut::GlobalShortcutExt;
let _ = app
.global_shortcut()
.on_shortcut("Ctrl+Shift+P", move |_app, _shortcut, event| {
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
let state: State<AppState> = _app.state();
let mut timer = state.timer.lock().unwrap();
timer.toggle_timer();
}
});
let _ = app
.global_shortcut()
.on_shortcut("Ctrl+Shift+B", move |_app, _shortcut, event| {
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
let state: State<AppState> = _app.state();
let mut timer = state.timer.lock().unwrap();
let payload = timer.start_break_now();
if let Some(ref p) = payload {
handle_break_start(_app, p.fullscreen_mode);
}
}
});
let _ = app
.global_shortcut()
.on_shortcut("Ctrl+Shift+S", move |_app, _shortcut, event| {
if event.state == tauri_plugin_global_shortcut::ShortcutState::Pressed {
if let Some(window) = _app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
}
});
}
// ── Break Window ────────────────────────────────────────────────────────────
fn open_break_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("break") {
let _ = win.show();
let _ = win.set_focus();
}
}
fn close_break_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("break") {
let _ = win.hide();
}
}
/// Handle break start: either fullscreen on main window, or open a separate modal break window.
fn handle_break_start(app: &AppHandle, fullscreen_mode: bool) {
if fullscreen_mode {
// Fullscreen: show break inside the main window
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_always_on_top(true);
let _ = window.show();
let _ = window.set_focus();
let _ = window.set_fullscreen(true);
}
} else {
// Modal: open a separate centered break window
open_break_window(app);
}
}
/// Handle break end: restore main window state and close break window if open.
fn handle_break_end(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_always_on_top(false);
let _ = window.set_fullscreen(false);
}
close_break_window(app);
}
// ── Mini Mode ──────────────────────────────────────────────────────────────
fn toggle_mini_window(app: &AppHandle) {
if let Some(mini) = app.get_webview_window("mini") {
// Close existing mini window
let _ = mini.close();
} else {
// Read saved position from config
let (mx, my) = {
let st: State<AppState> = app.state();
let timer = st.timer.lock().unwrap();
(
timer.pending_config.mini_window_x,
timer.pending_config.mini_window_y,
)
};
// Portable data directory for WebView2
let data_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("data")))
.unwrap_or_else(|| std::path::PathBuf::from("data"));
// Create mini window
let mut builder = tauri::WebviewWindowBuilder::new(
app,
"mini",
tauri::WebviewUrl::App("index.html?mini=1".into()),
)
.title("Core Cooldown")
.inner_size(184.0, 92.0)
.decorations(false)
.transparent(true)
.shadow(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.data_directory(data_dir);
if let (Some(x), Some(y)) = (mx, my) {
builder = builder.position(x as f64, y as f64);
}
let _ = builder.build();
}
}