use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use crate::timer::{Phase, Plan};
pub const APP_DIR: &str = "taskbar-focus";
pub const CONFIG_FILE: &str = "config.toml";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Preset {
pub name: String,
pub focus_minutes: f64,
pub short_break_minutes: f64,
pub long_break_minutes: f64,
#[serde(default = "default_sessions")]
pub sessions_before_long_break: u32,
}
fn default_sessions() -> u32 {
4
}
impl Preset {
pub fn duration_of(&self, phase: Phase) -> Duration {
let mins = match phase {
Phase::Focus => self.focus_minutes,
Phase::ShortBreak => self.short_break_minutes,
Phase::LongBreak => self.long_break_minutes,
};
Duration::from_secs_f64(if mins.is_finite() && mins > 0.0 {
mins * 60.0
} else {
60.0
})
}
}
fn default_presets() -> Vec<Preset> {
vec![
Preset {
name: "Pomodoro 25/5".into(),
focus_minutes: 25.0,
short_break_minutes: 5.0,
long_break_minutes: 15.0,
sessions_before_long_break: 4,
},
Preset {
name: "Deep Work 90/15".into(),
focus_minutes: 90.0,
short_break_minutes: 15.0,
long_break_minutes: 30.0,
sessions_before_long_break: 2,
},
Preset {
name: "Short 15/3".into(),
focus_minutes: 15.0,
short_break_minutes: 3.0,
long_break_minutes: 10.0,
sessions_before_long_break: 4,
},
]
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum WakePolicy {
#[default]
CountSleep,
IgnoreSleep,
Pause,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Behavior {
pub sequence_enabled: bool,
pub auto_start_break: bool,
pub auto_start_focus: bool,
pub strict_focus: bool,
pub restore_session_on_restart: bool,
pub wake_policy: WakePolicy,
}
impl Default for Behavior {
fn default() -> Self {
Self {
sequence_enabled: true,
auto_start_break: true,
auto_start_focus: false,
strict_focus: false,
restore_session_on_restart: true,
wake_policy: WakePolicy::default(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct DndSettings {
pub enabled: bool,
pub mute_tray_icon: bool,
pub mute_window: bool,
}
impl Default for DndSettings {
fn default() -> Self {
Self {
enabled: true,
mute_tray_icon: true,
mute_window: true,
}
}
}
impl DndSettings {
pub fn wants_indicator(&self) -> bool {
self.mute_tray_icon || self.mute_window
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Display {
pub mini_window: bool,
pub always_on_top: bool,
pub mini_geometry: Option<[i32; 4]>,
}
impl Default for Display {
fn default() -> Self {
Self {
mini_window: false,
always_on_top: true,
mini_geometry: None,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct EventToggles {
pub focus_start: bool,
pub focus_end: bool,
pub break_start: bool,
pub break_end: bool,
}
impl Default for EventToggles {
fn default() -> Self {
Self {
focus_start: true,
focus_end: true,
break_start: true,
break_end: true,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Notifications {
pub enabled: bool,
pub events: EventToggles,
}
impl Default for Notifications {
fn default() -> Self {
Self {
enabled: true,
events: EventToggles::default(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Sounds {
pub muted: bool,
pub events: EventToggles,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Hotkeys {
pub enabled: bool,
pub toggle: String,
pub skip: String,
}
impl Default for Hotkeys {
fn default() -> Self {
Self {
enabled: true,
toggle: "Ctrl+Alt+F".into(),
skip: "Ctrl+Alt+B".into(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub active_preset: String,
pub presets: Vec<Preset>,
pub behavior: Behavior,
pub dnd: DndSettings,
pub display: Display,
pub notifications: Notifications,
pub sounds: Sounds,
pub hotkeys: Hotkeys,
}
impl Default for Config {
fn default() -> Self {
Self {
active_preset: "Pomodoro 25/5".into(),
presets: default_presets(),
behavior: Behavior::default(),
dnd: DndSettings::default(),
display: Display::default(),
notifications: Notifications::default(),
sounds: Sounds::default(),
hotkeys: Hotkeys::default(),
}
}
}
impl Config {
pub fn preset(&self) -> Preset {
self.presets
.iter()
.find(|p| p.name.eq_ignore_ascii_case(&self.active_preset))
.or_else(|| self.presets.first())
.cloned()
.unwrap_or_else(|| default_presets().remove(0))
}
pub fn plan(&self) -> Plan {
let p = self.preset();
Plan {
focus: p.duration_of(Phase::Focus),
short_break: p.duration_of(Phase::ShortBreak),
long_break: p.duration_of(Phase::LongBreak),
sessions_before_long_break: p.sessions_before_long_break.max(1),
sequence_enabled: self.behavior.sequence_enabled,
auto_start_break: self.behavior.auto_start_break,
auto_start_focus: self.behavior.auto_start_focus,
}
}
pub fn select_preset(&mut self, name: &str) -> bool {
if let Some(p) = self
.presets
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
{
self.active_preset = p.name.clone();
true
} else {
false
}
}
pub fn load() -> Self {
let path = match config_path() {
Some(p) => p,
None => return Config::default(),
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => return Config::default(),
};
match toml::from_str::<Config>(&text) {
Ok(mut c) => {
if c.presets.is_empty() {
c.presets = default_presets();
}
c
}
Err(e) => {
eprintln!(
"taskbar-focus: {} is invalid ({e}); using defaults",
path.display()
);
let _ = std::fs::rename(&path, path.with_extension("toml.bad"));
Config::default()
}
}
}
pub fn save(&self) -> std::io::Result<()> {
let path = config_path().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "no config directory")
})?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let body = toml::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let text = format!(
"# taskbar-focus configuration.\n\
# Edit freely - unknown or missing keys fall back to defaults.\n\
# Durations are in minutes and may be fractional.\n\n{body}"
);
let tmp = path.with_extension("toml.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &path)
}
}
pub fn app_dir() -> Option<PathBuf> {
roaming_appdata().map(|d| d.join(APP_DIR))
}
pub fn config_path() -> Option<PathBuf> {
app_dir().map(|d| d.join(CONFIG_FILE))
}
pub fn is_first_run() -> bool {
!config_path().is_some_and(|p| p.exists())
}
#[cfg(windows)]
fn roaming_appdata() -> Option<PathBuf> {
use windows::Win32::System::Com::CoTaskMemFree;
use windows::Win32::UI::Shell::{
FOLDERID_RoamingAppData, SHGetKnownFolderPath, KF_FLAG_DEFAULT,
};
unsafe {
let pw = SHGetKnownFolderPath(&FOLDERID_RoamingAppData, KF_FLAG_DEFAULT, None).ok()?;
let s = pw.to_string().ok()?;
CoTaskMemFree(Some(pw.0 as *const _));
Some(PathBuf::from(s))
}
}
#[cfg(not(windows))]
fn roaming_appdata() -> Option<PathBuf> {
std::env::var_os("APPDATA").map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn partial_file_fills_in_defaults() {
let text = r#"
active_preset = "Deep Work 90/15"
[behavior]
strict_focus = true
"#;
let c: Config = toml::from_str(text).unwrap();
assert!(c.behavior.strict_focus);
assert!(
c.behavior.sequence_enabled,
"unspecified keys keep defaults"
);
assert!(c.dnd.enabled);
assert_eq!(c.presets, default_presets());
assert_eq!(c.plan().focus, Duration::from_secs(90 * 60));
}
#[test]
fn nonsense_durations_are_clamped() {
let p = Preset {
name: "bad".into(),
focus_minutes: -5.0,
short_break_minutes: f64::NAN,
long_break_minutes: 0.0,
sessions_before_long_break: 0,
};
for ph in [Phase::Focus, Phase::ShortBreak, Phase::LongBreak] {
assert_eq!(p.duration_of(ph), Duration::from_secs(60));
}
}
}