TrayTempo v0.2.0 : minuteur Pomodoro Windows en Nim avec interface intuitive

This commit is contained in:
2026-08-14 10:39:21 -04:00
commit 3218972ddf
26 changed files with 3154 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
## config.nim
## ===========
## Chargement / sauvegarde de la configuration (JSON dans %APPDATA%\TrayTempo)
## et gestion du démarrage automatique (clé de registre Run).
##
## Le module définit les types de données partagés (Config, Profile,
## HealthReminder) ainsi que les chemins des fichiers persistants.
import std/[json, os]
import winim/lean
import winim/inc/winreg
const
AppName* = "TrayTempo"
AppVersion* = "0.2.0"
RunKey* = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
DefaultHotkeyModifiers* = MOD_CONTROL or MOD_ALT ## Ctrl+Alt
DefaultHotkeyVk* = 0x50'i32 ## touche 'P'
type
## Langue de l'interface.
Lang* = enum
langAuto, langFr, langEn
## Un rappel santé affiché pendant les pauses.
HealthReminder* = object
text*: string ## Texte du rappel.
enabled*: bool ## Activé / désactivé par l'utilisateur.
## Un profil Pomodoro (durées + cycles).
Profile* = object
name*: string
workMin*: int
shortBreakMin*: int
longBreakMin*: int
cycles*: int ## Nombre de cycles avant une pause longue.
## Configuration complète de l'application.
Config* = object
workMin*: int ## Durée de travail par défaut.
shortBreakMin*: int ## Pause courte par défaut.
longBreakMin*: int ## Pause longue par défaut.
cyclesBeforeLongBreak*: int ## Cycles avant pause longue.
soundEnabled*: bool ## Son à la fin d'une période.
doNotDisturb*: bool ## Suspend les rappels santé (garde le timer).
autoStart*: bool ## Démarrage avec Windows.
activeProfile*: string ## Nom du profil actif ("" => défaut).
lang*: string ## "auto", "fr" ou "en".
dailyGoal*: int ## Objectif de pomodoros par jour (0 = aucun).
hotkeyModifiers*: int32 ## Modificateurs du raccourci global.
hotkeyVk*: int32 ## Code de touche virtuelle du raccourci.
soundName*: string ## Alias sonore système (fin de période).
floatingWindow*: bool ## Affiche une petite fenêtre flottante.
profiles*: seq[Profile]
healthReminders*: seq[HealthReminder]
# ---------------------------------------------------------------------------
# Valeurs par défaut
# ---------------------------------------------------------------------------
proc defaultReminders*(lang: Lang = langFr): seq[HealthReminder] =
if lang == langFr:
@[
HealthReminder(text: "Lève-toi et marche 2 minutes", enabled: true),
HealthReminder(text: "Va boire un verre d'eau", enabled: true),
HealthReminder(text: "Fais 10 push-ups", enabled: true),
HealthReminder(text: "Règle 20-20-20 : regarde à 20 pieds pendant 20 secondes", enabled: true),
HealthReminder(text: "Étire ton cou et tes épaules", enabled: true),
]
else:
@[
HealthReminder(text: "Stand up and walk for 2 minutes", enabled: true),
HealthReminder(text: "Go drink a glass of water", enabled: true),
HealthReminder(text: "Do 10 push-ups", enabled: true),
HealthReminder(text: "20-20-20 rule: look 20 feet away for 20 seconds", enabled: true),
HealthReminder(text: "Stretch your neck and shoulders", enabled: true),
]
proc defaultProfiles*(lang: Lang = langFr): seq[Profile] =
if lang == langFr:
@[
Profile(name: "Classique", workMin: 25, shortBreakMin: 5, longBreakMin: 15, cycles: 4),
Profile(name: "Deep Work", workMin: 50, shortBreakMin: 10, longBreakMin: 20, cycles: 4),
Profile(name: "Sprint", workMin: 15, shortBreakMin: 3, longBreakMin: 10, cycles: 6),
]
else:
@[
Profile(name: "Classic", workMin: 25, shortBreakMin: 5, longBreakMin: 15, cycles: 4),
Profile(name: "Deep Work", workMin: 50, shortBreakMin: 10, longBreakMin: 20, cycles: 4),
Profile(name: "Sprint", workMin: 15, shortBreakMin: 3, longBreakMin: 10, cycles: 6),
]
proc defaultConfig*(lang: Lang = langFr): Config =
let profiles = defaultProfiles(lang)
Config(
workMin: 25,
shortBreakMin: 5,
longBreakMin: 15,
cyclesBeforeLongBreak: 4,
soundEnabled: true,
doNotDisturb: false,
autoStart: false,
activeProfile: profiles[0].name,
lang: "auto",
dailyGoal: 0,
hotkeyModifiers: DefaultHotkeyModifiers,
hotkeyVk: DefaultHotkeyVk,
soundName: "SystemAsterisk",
floatingWindow: false,
profiles: profiles,
healthReminders: defaultReminders(lang),
)
# ---------------------------------------------------------------------------
# Chemins persistants
# ---------------------------------------------------------------------------
proc configDir*(): string =
## Répertoire de configuration : `%APPDATA%\TrayTempo`, sauf en mode
## portable (fichier `portable` placé à côté de l'exécutable) où l'on utilise
## le répertoire de l'exécutable lui-même.
let exeDir = getAppFilename().parentDir
if fileExists(exeDir / "portable"):
return exeDir
getEnv("APPDATA") / AppName
proc configPath*(): string =
configDir() / "config.json"
proc statsPath*(): string =
configDir() / "stats.json"
proc ensureConfigDir*() =
## Crée le répertoire de configuration s'il n'existe pas.
createDir(configDir())
# ---------------------------------------------------------------------------
# Résolution du profil actif
# ---------------------------------------------------------------------------
proc getActiveProfile*(cfg: Config): Profile =
## Retourne le profil actif, ou un profil synthétique basé sur les valeurs
## par défaut si aucun profil ne correspond.
for p in cfg.profiles:
if p.name == cfg.activeProfile:
return p
Profile(
name: "Défaut",
workMin: cfg.workMin,
shortBreakMin: cfg.shortBreakMin,
longBreakMin: cfg.longBreakMin,
cycles: cfg.cyclesBeforeLongBreak,
)
# ---------------------------------------------------------------------------
# Chargement / sauvegarde
# ---------------------------------------------------------------------------
proc loadConfig*(): Config =
## Charge la configuration depuis le disque. En cas d'erreur ou de fichier
## manquant, on conserve des valeurs par défaut (jamais de crash au boot).
result = defaultConfig()
try:
let path = configPath()
if not fileExists(path):
return
let n = parseFile(path)
result.workMin = n.getOrDefault("workMin").getInt(result.workMin)
result.shortBreakMin = n.getOrDefault("shortBreakMin").getInt(result.shortBreakMin)
result.longBreakMin = n.getOrDefault("longBreakMin").getInt(result.longBreakMin)
result.cyclesBeforeLongBreak =
n.getOrDefault("cyclesBeforeLongBreak").getInt(result.cyclesBeforeLongBreak)
result.soundEnabled = n.getOrDefault("soundEnabled").getBool(result.soundEnabled)
result.doNotDisturb = n.getOrDefault("doNotDisturb").getBool(result.doNotDisturb)
result.autoStart = n.getOrDefault("autoStart").getBool(result.autoStart)
result.activeProfile = n.getOrDefault("activeProfile").getStr(result.activeProfile)
result.lang = n.getOrDefault("lang").getStr(result.lang)
result.dailyGoal = n.getOrDefault("dailyGoal").getInt(result.dailyGoal)
result.hotkeyModifiers = int32(n.getOrDefault("hotkeyModifiers").getInt(result.hotkeyModifiers.int))
result.hotkeyVk = int32(n.getOrDefault("hotkeyVk").getInt(result.hotkeyVk.int))
result.soundName = n.getOrDefault("soundName").getStr(result.soundName)
result.floatingWindow = n.getOrDefault("floatingWindow").getBool(result.floatingWindow)
let pro = n.getOrDefault("profiles")
if not pro.isNil and pro.kind == JArray:
result.profiles = @[]
for p in pro:
result.profiles.add Profile(
name: p.getOrDefault("name").getStr(),
workMin: p.getOrDefault("workMin").getInt(25),
shortBreakMin: p.getOrDefault("shortBreakMin").getInt(5),
longBreakMin: p.getOrDefault("longBreakMin").getInt(15),
cycles: p.getOrDefault("cycles").getInt(4),
)
let rem = n.getOrDefault("healthReminders")
if not rem.isNil and rem.kind == JArray:
result.healthReminders = @[]
for r in rem:
result.healthReminders.add HealthReminder(
text: r.getOrDefault("text").getStr(),
enabled: r.getOrDefault("enabled").getBool(true),
)
except:
# Fichier corrompu : on garde les valeurs par défaut.
discard
proc saveConfig*(cfg: Config) =
## Sauvegarde la configuration au format JSON (pretty-printé).
try:
ensureConfigDir()
var n = newJObject()
n["workMin"] = %cfg.workMin
n["shortBreakMin"] = %cfg.shortBreakMin
n["longBreakMin"] = %cfg.longBreakMin
n["cyclesBeforeLongBreak"] = %cfg.cyclesBeforeLongBreak
n["soundEnabled"] = %cfg.soundEnabled
n["doNotDisturb"] = %cfg.doNotDisturb
n["autoStart"] = %cfg.autoStart
n["activeProfile"] = %cfg.activeProfile
n["lang"] = %cfg.lang
n["dailyGoal"] = %cfg.dailyGoal
n["hotkeyModifiers"] = %cfg.hotkeyModifiers
n["hotkeyVk"] = %cfg.hotkeyVk
n["soundName"] = %cfg.soundName
n["floatingWindow"] = %cfg.floatingWindow
var pro = newJArray()
for p in cfg.profiles:
var o = newJObject()
o["name"] = %p.name
o["workMin"] = %p.workMin
o["shortBreakMin"] = %p.shortBreakMin
o["longBreakMin"] = %p.longBreakMin
o["cycles"] = %p.cycles
pro.add o
n["profiles"] = pro
var rem = newJArray()
for r in cfg.healthReminders:
var o = newJObject()
o["text"] = %r.text
o["enabled"] = %r.enabled
rem.add o
n["healthReminders"] = rem
writeFile(configPath(), n.pretty)
except:
discard
# ---------------------------------------------------------------------------
# Démarrage automatique (registre)
# ---------------------------------------------------------------------------
proc isAutostartEnabled*(): bool =
## Vérifie la présence de la valeur TrayTempo dans la clé Run.
var hKey: HKEY
if RegOpenKeyEx(HKEY_CURRENT_USER, RunKey, 0, KEY_QUERY_VALUE, &hKey) == ERROR_SUCCESS:
var dataType: DWORD = 0
var dataSize: DWORD = 0
let ok = RegQueryValueEx(hKey, AppName, nil, &dataType, nil, &dataSize) == ERROR_SUCCESS
RegCloseKey(hKey)
return ok and dataSize > 0
return false
proc setAutostart*(enable: bool): bool =
## Active (écrit la clé) ou désactive (supprime la clé) le lancement au
## démarrage. Retourne true en cas de succès.
result = false
var hKey: HKEY
if RegOpenKeyEx(HKEY_CURRENT_USER, RunKey, 0,
KEY_SET_VALUE or KEY_QUERY_VALUE, &hKey) == ERROR_SUCCESS:
if enable:
let exePath = getAppFilename()
let value = "\"" & exePath & "\""
let wval = +$value # UTF-8 -> UTF-16
let cbData = DWORD((wval.len + 1) * 2) # inclut le null terminal
if RegSetValueEx(hKey, AppName, 0, REG_SZ,
cast[ptr BYTE](&wval), cbData) == ERROR_SUCCESS:
result = true
else:
if RegDeleteValue(hKey, AppName) == ERROR_SUCCESS:
result = true
RegCloseKey(hKey)
# ---------------------------------------------------------------------------
# Mono-instance (mutex nommé)
# ---------------------------------------------------------------------------
var gInstanceMutex: HANDLE
proc acquireSingleInstance*(name: string): bool =
## Tente d'acquérir un mutex nommé global. Retourne true si une autre
## instance détient déjà ce mutex (application déjà lancée). Le handle est
## conservé dans `gInstanceMutex` pour toute la durée du processus.
gInstanceMutex = CreateMutexW(nil, FALSE, +$name)
result = GetLastError() == ERROR_ALREADY_EXISTS
+611
View File
@@ -0,0 +1,611 @@
## dialogs.nim
## ============
## Interfaces graphiques natives (Win32) construites sans fichier de ressource :
## panneau de réglages, édition des rappels santé, assistant de premier
## lancement et boîte de dialogue de statistiques.
##
## Ces dialogues sont modaux et utilisent des fenêtres/contrôles créés à la
## volée via CreateWindowExW. Les valeurs sont lues/écrites dans un objet
## `Config` passé par référence.
import winim/lean
import winim/inc/commctrl
import std/strutils
import config
import i18n
import stats
# ---------------------------------------------------------------------------
# Identifiants des contrôles
# ---------------------------------------------------------------------------
const
# Réglages
IdEditWork = 2001
IdEditShortBreak = 2002
IdEditLongBreak = 2003
IdEditCycles = 2004
IdChkSound = 2005
IdChkDnd = 2006
IdChkAutoStart = 2007
IdEditGoal = 2008
IdRadioAuto = 2009
IdRadioFr = 2010
IdRadioEn = 2011
IdBtnSave = 2012
IdBtnCancel = 2013
IdBtnReminders = 2014
# Rappels santé (liste + boutons)
IdRemList = 2100
IdRemAdd = 2101
IdRemEdit = 2102
IdRemRemove = 2103
IdRemOk = 2104
IdRemCancel = 2105
# Invite de saisie de texte (ajout/édition de rappel)
IdPromptEdit = 2110
IdPromptOk = 2111
IdPromptCancel = 2112
# Assistant
IdWizBack = 2201
IdWizNext = 2202
IdWizRadioBase = 2203
IdWizChkBase = 2250
# ---------------------------------------------------------------------------
# État global des dialogues (ils sont modaux et mono-thread, donc sûrs)
# ---------------------------------------------------------------------------
var
dlgCfg: Config
dlgClosed: bool
dlgSaved: bool
dlgHwnd: HWND
dlgParent: HWND
# Assistant
wizStep: int
wizCfg: Config
# ---------------------------------------------------------------------------
# Helpers de création de contrôles
# ---------------------------------------------------------------------------
proc makeCtrl(cls, text: string, style: int, x, y, w, h: int, id: int32,
parent: HWND, hInstance: HINSTANCE): HWND =
CreateWindowExW(0, cls, text, DWORD(style), int32(x), int32(y), int32(w),
int32(h), parent, cast[HMENU](id), hInstance, nil)
proc label(text: string, x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE): HWND =
makeCtrl("STATIC", text, WS_CHILD or WS_VISIBLE or SS_LEFT, x, y, w, h, id, parent, inst)
proc editNum(x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE): HWND =
makeCtrl("EDIT", "", WS_CHILD or WS_VISIBLE or WS_BORDER or WS_TABSTOP or ES_NUMBER,
x, y, w, h, id, parent, inst)
proc checkbox(text: string, x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE): HWND =
makeCtrl("BUTTON", text, WS_CHILD or WS_VISIBLE or BS_AUTOCHECKBOX or WS_TABSTOP,
x, y, w, h, id, parent, inst)
proc radio(text: string, x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE,
group: bool): HWND =
let style = WS_CHILD or WS_VISIBLE or BS_AUTORADIOBUTTON or WS_TABSTOP or
(if group: WS_GROUP else: 0)
makeCtrl("BUTTON", text, style, x, y, w, h, id, parent, inst)
proc button(text: string, x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE): HWND =
makeCtrl("BUTTON", text, WS_CHILD or WS_VISIBLE or WS_TABSTOP, x, y, w, h, id, parent, inst)
proc getEditInt(hwnd: HWND, id: int32, dflt: int): int =
var ok: BOOL
let v = GetDlgItemInt(hwnd, id, &ok, TRUE)
if ok != 0: int(v) else: dflt
proc setEditInt(hwnd: HWND, id: int32, value: int) =
discard SetDlgItemInt(hwnd, id, UINT(value), TRUE)
proc isChecked(hwnd: HWND, id: int32): bool =
SendMessage(GetDlgItem(hwnd, id), BM_GETCHECK, 0, 0) == BST_CHECKED
proc setChecked(hwnd: HWND, id: int32, value: bool) =
discard SendMessage(GetDlgItem(hwnd, id), BM_SETCHECK,
if value: BST_CHECKED else: BST_UNCHECKED, 0)
proc centerWindow(hwnd: HWND, w, h: int32) =
let scrW = GetSystemMetrics(SM_CXSCREEN)
let scrH = GetSystemMetrics(SM_CYSCREEN)
let x = (scrW - w) div 2
let y = (scrH - h) div 2
discard SetWindowPos(hwnd, 0, x, y, w, h, SWP_NOZORDER)
# ---------------------------------------------------------------------------
# Boîte de dialogue des statistiques
# ---------------------------------------------------------------------------
proc showStatsDialog*(hwnd: HWND, cfg: Config, st: Stats) =
var msg = S.statsToday & " : " & $st.completedToday()
msg &= "\n" & S.statsWeek & " : " & $st.completedThisWeek()
msg &= "\n" & S.statsStreak & " : " & $st.streak() & " " & S.statsDays
msg &= "\n" & S.statsTotal & " : " & $st.totalPomodoros()
if cfg.dailyGoal > 0:
let done = st.completedToday()
msg &= "\n\n" & S.statsGoal & " : " & $done & " / " & $cfg.dailyGoal
if done >= cfg.dailyGoal:
msg &= " ✓"
msg &= "\n\n---\n" & S.statsDayWord & " " & "count"
for (date, n) in st.lastNDays(7):
msg &= "\n" & date & " " & $n
discard MessageBox(hwnd, msg, S.statsTitle, MB_OK or MB_ICONINFORMATION)
# ---------------------------------------------------------------------------
# Boîte de dialogue des rappels santé : liste modifiable (ajouter / modifier /
# supprimer / activer-désactiver), avec invite de saisie pour le texte.
# ---------------------------------------------------------------------------
var
commonControlsInited = false
remListHwnd: HWND
remWorking: seq[HealthReminder] ## Copie de travail (validée à la fermeture).
promptResult: string
promptDone: bool
proc ensureCommonControls() =
if not commonControlsInited:
var icc: TINITCOMMONCONTROLSEX
icc.dwSize = DWORD(sizeof(TINITCOMMONCONTROLSEX))
icc.dwICC = ICC_LISTVIEW_CLASSES
discard InitCommonControlsEx(&icc)
commonControlsInited = true
proc refreshReminderList(list: HWND) =
## Reconstruit la liste à partir de la copie de travail.
discard SendMessage(list, LVM_DELETEALLITEMS, 0, 0)
for i, r in remWorking:
let wtext = +$r.text
var item: LVITEM
item.mask = LVIF_TEXT
item.iItem = int32(i)
item.pszText = cast[LPWSTR](&wtext)
ListView_InsertItem(list, &item)
ListView_SetItemState(list, int32(i),
UINT(INDEXTOSTATEIMAGEMASK(if r.enabled: 2 else: 1)),
UINT(LVIS_STATEIMAGEMASK))
proc selectedReminderIndex(list: HWND): int =
int(ListView_GetNextItem(list, -1, LVNI_SELECTED))
proc getEditText(hwnd: HWND, id: int32): string =
## Lit le texte d'un contrôle EDIT et le convertit en chaîne UTF-8.
var buf: array[512, WCHAR]
let n = GetDlgItemTextW(hwnd, id, cast[LPWSTR](addr buf[0]), 512)
if n > 0:
result = $cast[WideCString](addr buf[0])
proc promptProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
case msg
of WM_COMMAND:
let id = int(wParam and 0xFFFF)
case id
of IdPromptOk:
promptResult = getEditText(hwnd, IdPromptEdit)
promptDone = true
DestroyWindow(hwnd)
of IdPromptCancel:
promptResult = ""
promptDone = true
DestroyWindow(hwnd)
else: discard
return 0
of WM_DESTROY:
promptDone = true
return 0
else: discard
return DefWindowProc(hwnd, msg, wParam, lParam)
proc promptText(parent: HWND, inst: HINSTANCE, title, initial: string): string =
## Petite boîte de saisie d'une ligne. Retourne "" si annulé.
promptResult = ""
promptDone = false
let w = 360'i32
let h = 140'i32
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](promptProc)
wc.hInstance = inst
wc.lpszClassName = L"TrayTempoPromptClass"
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
discard RegisterClass(&wc)
let hwnd = CreateWindowExW(0, L"TrayTempoPromptClass", title,
WS_CAPTION or WS_SYSMENU or WS_VISIBLE,
0, 0, w, h, parent, 0, inst, nil)
centerWindow(hwnd, w, h)
discard label(S.reminderTextPrompt, 12, 16, 320, 18, 0, hwnd, inst)
discard makeCtrl("EDIT", initial, WS_CHILD or WS_VISIBLE or WS_BORDER or WS_TABSTOP,
12, 38, w - 24, 24, IdPromptEdit, hwnd, inst)
discard button(S.btnOk, w - 210, h - 50, 95, 28, IdPromptOk, hwnd, inst)
discard button(S.btnCancel, w - 105, h - 50, 95, 28, IdPromptCancel, hwnd, inst)
var m: MSG
while not promptDone and GetMessage(&m, 0, 0, 0) != 0:
if not IsDialogMessage(hwnd, &m):
TranslateMessage(&m)
DispatchMessage(&m)
result = promptResult
proc remindersProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
case msg
of WM_COMMAND:
let id = int(wParam and 0xFFFF)
case id
of IdRemAdd:
let text = promptText(hwnd, GetModuleHandleW(nil), S.reminderNewTitle, "")
if text.strip().len > 0:
remWorking.add HealthReminder(text: text, enabled: true)
refreshReminderList(remListHwnd)
of IdRemEdit:
let idx = selectedReminderIndex(remListHwnd)
if idx >= 0 and idx < remWorking.len:
let text = promptText(hwnd, GetModuleHandleW(nil), S.reminderEditTitle,
remWorking[idx].text)
if text.strip().len > 0:
remWorking[idx].text = text
refreshReminderList(remListHwnd)
of IdRemRemove:
let idx = selectedReminderIndex(remListHwnd)
if idx >= 0 and idx < remWorking.len:
remWorking.delete(idx)
refreshReminderList(remListHwnd)
of IdRemOk:
for i in 0 ..< remWorking.len:
remWorking[i].enabled = ListView_GetCheckState(remListHwnd, UINT(i)) != 0
dlgSaved = true
dlgClosed = true
DestroyWindow(hwnd)
of IdRemCancel:
dlgClosed = true
DestroyWindow(hwnd)
else: discard
return 0
of WM_DESTROY:
dlgClosed = true
return 0
else: discard
return DefWindowProc(hwnd, msg, wParam, lParam)
proc showRemindersDialog(parent: HWND, inst: HINSTANCE, cfg: var Config) =
## Dialogue de gestion des rappels santé. Les modifications ne sont appliquées
## qu'à la validation (bouton OK).
remWorking = cfg.healthReminders
dlgClosed = false
dlgSaved = false
ensureCommonControls()
let w = 440'i32
let h = 340'i32
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](remindersProc)
wc.hInstance = inst
wc.lpszClassName = L"TrayTempoRemindersClass"
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
discard RegisterClass(&wc)
let hwnd = CreateWindowExW(0, L"TrayTempoRemindersClass", S.settingsHealth,
WS_CAPTION or WS_SYSMENU or WS_VISIBLE,
0, 0, w, h, parent, 0, inst, nil)
centerWindow(hwnd, w, h)
remListHwnd = makeCtrl("SysListView32", "", WS_CHILD or WS_VISIBLE or WS_TABSTOP or
LVS_REPORT or LVS_SINGLESEL or LVS_SHOWSELALWAYS,
12, 12, w - 120, h - 80, IdRemList, hwnd, inst)
ListView_SetExtendedListViewStyle(remListHwnd, LVS_EX_CHECKBOXES or LVS_EX_FULLROWSELECT)
# Une colonne pleine largeur pour afficher le texte des rappels.
var col: LVCOLUMN
col.mask = LVCF_WIDTH
col.cx = w - 140
discard SendMessage(remListHwnd, LVM_INSERTCOLUMN, 0, cast[LPARAM](&col))
discard button(S.reminderAdd, w - 100, 12, 88, 28, IdRemAdd, hwnd, inst)
discard button(S.reminderEdit, w - 100, 46, 88, 28, IdRemEdit, hwnd, inst)
discard button(S.reminderRemove, w - 100, 80, 88, 28, IdRemRemove, hwnd, inst)
discard button(S.btnOk, w - 210, h - 50, 95, 28, IdRemOk, hwnd, inst)
discard button(S.btnCancel, w - 105, h - 50, 95, 28, IdRemCancel, hwnd, inst)
refreshReminderList(remListHwnd)
var m: MSG
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
if not IsDialogMessage(hwnd, &m):
TranslateMessage(&m)
DispatchMessage(&m)
if dlgSaved:
cfg.healthReminders = remWorking
# ---------------------------------------------------------------------------
# Panneau de réglages
# ---------------------------------------------------------------------------
proc saveSettingsFromControls(hwnd: HWND, cfg: var Config) =
cfg.workMin = getEditInt(hwnd, IdEditWork, cfg.workMin)
cfg.shortBreakMin = getEditInt(hwnd, IdEditShortBreak, cfg.shortBreakMin)
cfg.longBreakMin = getEditInt(hwnd, IdEditLongBreak, cfg.longBreakMin)
cfg.cyclesBeforeLongBreak = getEditInt(hwnd, IdEditCycles, cfg.cyclesBeforeLongBreak)
cfg.dailyGoal = getEditInt(hwnd, IdEditGoal, cfg.dailyGoal)
cfg.soundEnabled = isChecked(hwnd, IdChkSound)
cfg.doNotDisturb = isChecked(hwnd, IdChkDnd)
cfg.autoStart = isChecked(hwnd, IdChkAutoStart)
if isChecked(hwnd, IdRadioFr): cfg.lang = "fr"
elif isChecked(hwnd, IdRadioEn): cfg.lang = "en"
else: cfg.lang = "auto"
proc settingsProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
case msg
of WM_COMMAND:
let id = int(wParam and 0xFFFF)
case id
of IdBtnSave:
saveSettingsFromControls(hwnd, dlgCfg)
dlgSaved = true
dlgClosed = true
DestroyWindow(hwnd)
of IdBtnCancel:
dlgClosed = true
DestroyWindow(hwnd)
of IdBtnReminders:
showRemindersDialog(hwnd, GetModuleHandleW(nil), dlgCfg)
else: discard
return 0
of WM_CLOSE:
dlgClosed = true
DestroyWindow(hwnd)
return 0
of WM_DESTROY:
dlgClosed = true
return 0
else: discard
return DefWindowProc(hwnd, msg, wParam, lParam)
proc buildSettingsDialog*(parent: HWND, cfg: Config): HWND =
## Construit la fenêtre et les contrôles du panneau de réglages (sans lancer
## la boucle modale). Retourne le HWND créé. Utilisé par `showSettingsDialog`
## et par les tests d'intégration.
dlgCfg = cfg
dlgClosed = false
dlgSaved = false
let inst = GetModuleHandleW(nil)
let w = 420'i32
let h = 430'i32
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](settingsProc)
wc.hInstance = inst
wc.lpszClassName = L"TrayTempoSettingsClass"
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
discard RegisterClass(&wc)
let hwnd = CreateWindowExW(0, L"TrayTempoSettingsClass", S.settingsTitle,
WS_CAPTION or WS_SYSMENU or WS_VISIBLE,
0, 0, w, h, parent, 0, inst, nil)
centerWindow(hwnd, w, h)
# --- Durées ---
var y = 12'i32
discard label(S.settingsWork, 12, y + 3, 120, 18, 0, hwnd, inst)
discard editNum(140, y, 60, 22, IdEditWork, hwnd, inst)
discard label(S.settingsMinutes, 206, y + 3, 40, 18, 0, hwnd, inst)
y += 28
discard label(S.settingsShortBreak, 12, y + 3, 120, 18, 0, hwnd, inst)
discard editNum(140, y, 60, 22, IdEditShortBreak, hwnd, inst)
discard label(S.settingsMinutes, 206, y + 3, 40, 18, 0, hwnd, inst)
y += 28
discard label(S.settingsLongBreak, 12, y + 3, 120, 18, 0, hwnd, inst)
discard editNum(140, y, 60, 22, IdEditLongBreak, hwnd, inst)
discard label(S.settingsMinutes, 206, y + 3, 40, 18, 0, hwnd, inst)
y += 28
discard label(S.settingsCycles, 12, y + 3, 180, 18, 0, hwnd, inst)
discard editNum(200, y, 60, 22, IdEditCycles, hwnd, inst)
y += 34
discard checkbox(S.settingsSound, 12, y, 240, 20, IdChkSound, hwnd, inst)
y += 24
discard checkbox(S.settingsDoNotDisturb, 12, y, 320, 20, IdChkDnd, hwnd, inst)
y += 24
discard checkbox(S.settingsAutoStart, 12, y, 240, 20, IdChkAutoStart, hwnd, inst)
y += 30
discard label(S.settingsGoal, 12, y + 3, 140, 18, 0, hwnd, inst)
discard editNum(160, y, 60, 22, IdEditGoal, hwnd, inst)
discard label(S.settingsGoalHint, 12, y + 24, 380, 16, 0, hwnd, inst)
y += 46
discard label(S.settingsLanguage, 12, y, 100, 18, 0, hwnd, inst)
discard radio("Auto", 120, y, 70, 20, IdRadioAuto, hwnd, inst, true)
discard radio("Français", 190, y, 90, 20, IdRadioFr, hwnd, inst, false)
discard radio("English", 285, y, 90, 20, IdRadioEn, hwnd, inst, false)
y += 30
discard button(S.settingsEditReminders, 12, y, 220, 28, IdBtnReminders, hwnd, inst)
# --- Boutons ---
discard button(S.settingsSave, w - 210, h - 62, 100, 30, IdBtnSave, hwnd, inst)
discard button(S.settingsCancel, w - 105, h - 62, 100, 30, IdBtnCancel, hwnd, inst)
# --- Pré-remplissage ---
setEditInt(hwnd, IdEditWork, cfg.workMin)
setEditInt(hwnd, IdEditShortBreak, cfg.shortBreakMin)
setEditInt(hwnd, IdEditLongBreak, cfg.longBreakMin)
setEditInt(hwnd, IdEditCycles, cfg.cyclesBeforeLongBreak)
setEditInt(hwnd, IdEditGoal, cfg.dailyGoal)
setChecked(hwnd, IdChkSound, cfg.soundEnabled)
setChecked(hwnd, IdChkDnd, cfg.doNotDisturb)
setChecked(hwnd, IdChkAutoStart, cfg.autoStart)
case cfg.lang
of "fr": setChecked(hwnd, IdRadioFr, true)
of "en": setChecked(hwnd, IdRadioEn, true)
else: setChecked(hwnd, IdRadioAuto, true)
discard SetFocus(GetDlgItem(hwnd, IdEditWork))
result = hwnd
proc showSettingsDialog*(parent: HWND, cfg: var Config): bool =
## Affiche le panneau de réglages. Retourne true si l'utilisateur a enregistré.
let hwnd = buildSettingsDialog(parent, cfg)
# Boucle de messages locale (dialogue modal).
var m: MSG
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
if not IsDialogMessage(hwnd, &m):
TranslateMessage(&m)
DispatchMessage(&m)
if dlgSaved:
cfg = dlgCfg
return dlgSaved
# ---------------------------------------------------------------------------
# Assistant de premier lancement
# ---------------------------------------------------------------------------
const WizSteps = 5 # 0 welcome, 1 langue, 2 profil, 3 santé, 4 fin
proc wizRebuild(hwnd: HWND, inst: HINSTANCE) =
# Efface tous les contrôles enfants existants.
var child = GetWindow(hwnd, GW_CHILD)
while child != 0:
let next = GetWindow(child, GW_HWNDNEXT)
DestroyWindow(child)
child = next
case wizStep
of 0:
discard label(S.wizardWelcomeTitle, 16, 16, 460, 26, 0, hwnd, inst)
discard label(S.wizardWelcomeBody, 16, 48, 460, 60, 0, hwnd, inst)
of 1:
discard label(S.wizardLangPrompt, 16, 16, 460, 22, 0, hwnd, inst)
discard radio("Français", 30, 48, 200, 22, IdWizRadioBase, hwnd, inst, true)
discard radio("English", 30, 76, 200, 22, int32(IdWizRadioBase + 1), hwnd, inst, false)
# Pré-sélection selon la langue détectée.
setChecked(hwnd, IdWizRadioBase, detectSystemLang() == langFr)
setChecked(hwnd, int32(IdWizRadioBase + 1), detectSystemLang() != langFr)
of 2:
discard label(S.wizardProfilePrompt, 16, 16, 460, 22, 0, hwnd, inst)
let profs = wizCfg.profiles
for i, p in profs:
let name = p.name & " (" & $p.workMin & "/" & $p.shortBreakMin & "/" &
$p.longBreakMin & " min)"
discard radio(name, 30, 48 + i * 26, 440, 22, int32(IdWizRadioBase + i), hwnd, inst, i == 0)
# Pré-sélectionne le profil actif.
for i, p in profs:
if p.name == wizCfg.activeProfile:
setChecked(hwnd, int32(IdWizRadioBase + i), true)
of 3:
discard label(S.wizardHealthPrompt, 16, 16, 460, 22, 0, hwnd, inst)
for i, r in wizCfg.healthReminders:
discard checkbox(r.text, 30, 48 + i * 26, 440, 22, int32(IdWizChkBase + i), hwnd, inst)
setChecked(hwnd, int32(IdWizChkBase + i), r.enabled)
of 4:
discard label(S.wizardDoneTitle, 16, 16, 460, 26, 0, hwnd, inst)
discard label(S.wizardDoneBody, 16, 48, 460, 80, 0, hwnd, inst)
else: discard
# Boutons Bas / Suivant / Terminer.
let backEnabled = wizStep > 0
discard button(S.btnCancel, 20, 300, 90, 30, IdWizBack, hwnd, inst)
if not backEnabled:
discard EnableWindow(GetDlgItem(hwnd, IdWizBack), FALSE)
let nextLabel = if wizStep == WizSteps - 1: S.settingsSave else: S.btnOk
discard button(nextLabel, 400, 300, 90, 30, IdWizNext, hwnd, inst)
proc wizCollectStep(hwnd: HWND) =
## Lit les choix de l'étape courante dans wizCfg.
case wizStep
of 1:
if isChecked(hwnd, IdWizRadioBase): wizCfg.lang = "fr"
elif isChecked(hwnd, int32(IdWizRadioBase + 1)): wizCfg.lang = "en"
of 2:
let profs = wizCfg.profiles
for i in 0 ..< profs.len:
if isChecked(hwnd, int32(IdWizRadioBase + i)):
wizCfg.activeProfile = profs[i].name
of 3:
for i in 0 ..< wizCfg.healthReminders.len:
wizCfg.healthReminders[i].enabled = isChecked(hwnd, int32(IdWizChkBase + i))
else: discard
proc wizardProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
case msg
of WM_COMMAND:
let id = int(wParam and 0xFFFF)
case id
of IdWizNext:
wizCollectStep(hwnd)
if wizStep < WizSteps - 1:
inc wizStep
wizRebuild(hwnd, GetModuleHandleW(nil))
else:
dlgSaved = true
dlgClosed = true
DestroyWindow(hwnd)
of IdWizBack:
if wizStep > 0:
dec wizStep
wizRebuild(hwnd, GetModuleHandleW(nil))
else: discard
return 0
of WM_CLOSE:
dlgClosed = true
DestroyWindow(hwnd)
return 0
of WM_DESTROY:
dlgClosed = true
return 0
else: discard
return DefWindowProc(hwnd, msg, wParam, lParam)
proc showFirstRunWizard*(cfg: var Config): bool =
## Assistant de premier lancement. Retourne true si l'utilisateur l'a terminé.
wizCfg = cfg
wizStep = 0
dlgClosed = false
dlgSaved = false
let inst = GetModuleHandleW(nil)
let w = 520'i32
let h = 360'i32
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](wizardProc)
wc.hInstance = inst
wc.lpszClassName = L"TrayTempoWizardClass"
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
discard RegisterClass(&wc)
let hwnd = CreateWindowExW(0, L"TrayTempoWizardClass", S.wizardWelcomeTitle,
WS_CAPTION or WS_SYSMENU or WS_VISIBLE,
0, 0, w, h, 0, 0, inst, nil)
centerWindow(hwnd, w, h)
wizRebuild(hwnd, inst)
var m: MSG
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
if not IsDialogMessage(hwnd, &m):
TranslateMessage(&m)
DispatchMessage(&m)
if dlgSaved:
cfg = wizCfg
return dlgSaved
+76
View File
@@ -0,0 +1,76 @@
## floating.nim
## =============
## Fenêtre flottante compacte : un petit panneau toujours au-dessus qui affiche
## la phase et le temps restant en grand. Utile pour voir l'état du minuteur
## d'un coup d'œil sans ouvrir le menu tray. Draggable à la souris.
import winim/lean
var
fHwnd: HWND
fLabel: HWND
fFont: HFONT
proc floatingProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
case msg
of WM_LBUTTONDOWN:
# Permet de déplacer la fenêtre en la glissant.
discard SendMessage(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0)
return 0
of WM_DESTROY:
fHwnd = 0
return 0
else:
discard
return DefWindowProc(hwnd, msg, wParam, lParam)
proc floatingVisible*(): bool =
fHwnd != 0
proc updateFloating*(text: string) =
## Met à jour le texte affiché (phase + temps restant).
if fHwnd != 0 and fLabel != 0:
discard SetWindowTextW(fLabel, text)
proc showFloating*() =
## Affiche (ou crée) la fenêtre flottante en bas à droite de l'écran.
if fHwnd != 0:
discard ShowWindow(fHwnd, SW_SHOW)
return
let inst = GetModuleHandleW(nil)
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](floatingProc)
wc.hInstance = inst
wc.lpszClassName = L"TrayTempoFloatingClass"
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
discard RegisterClass(&wc)
let w = 240'i32
let h = 56'i32
let scrW = GetSystemMetrics(SM_CXSCREEN)
let scrH = GetSystemMetrics(SM_CYSCREEN)
fHwnd = CreateWindowExW(WS_EX_TOPMOST or WS_EX_TOOLWINDOW,
L"TrayTempoFloatingClass", L"TrayTempo",
WS_POPUP or WS_BORDER or WS_VISIBLE,
scrW - w - 24, scrH - h - 80, w, h,
0, 0, inst, nil)
# Police large et lisible (accessibilité : texte agrandi).
fFont = CreateFontW(24, 0, 0, 0, FW_BOLD, 0, 0, 0, DEFAULT_CHARSET,
0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI")
fLabel = CreateWindowExW(0, L"STATIC", L"", WS_CHILD or WS_VISIBLE or SS_CENTER,
0, 0, w, h, fHwnd, cast[HMENU](1), inst, nil)
discard SendMessage(fLabel, WM_SETFONT, cast[WPARAM](fFont), 1)
proc hideFloating*() =
if fHwnd != 0:
discard DestroyWindow(fHwnd)
fHwnd = 0
fLabel = 0
if fFont != 0:
discard DeleteObject(fFont)
fFont = 0
+367
View File
@@ -0,0 +1,367 @@
## i18n.nim
## =========
## Internationalisation : toutes les chaînes visibles par l'utilisateur sont
## regroupées dans un objet `Strings`, décliné en français et en anglais.
## Un module global `S` référence le bundle actif. Le langage est choisi au
## démarrage (détection système ou préférence utilisateur dans config.json).
##
## Ce module importe `config` (types) et `timer` (pour la phase), mais il est
## lui-même importé par les modules d'interface (tray, notifications, traytempo).
import winim/lean
import config
import timer
# ---------------------------------------------------------------------------
# Chaînes d'interface (bundle actif)
# ---------------------------------------------------------------------------
type Strings* = object
appName*: string
# Phases du minuteur.
phaseIdle*: string
phaseWorking*: string
phaseShortBreak*: string
phaseLongBreak*: string
phasePaused*: string
# Tooltip de l'icône tray.
tipIdle*: string
tipPaused*: string
# Menu contextuel.
menuStart*: string
menuResume*: string
menuPause*: string
menuStop*: string
menuSkipBreak*: string
menuExtendBreak*: string
menuProfiles*: string
menuDoNotDisturb*: string
menuAutoStart*: string
menuFloatingWindow*: string
menuSettings*: string
menuStats*: string
menuExportCsv*: string
menuQuit*: string
# Notifications.
notifWorkDone*: string
notifBreakDone*: string
notifBackToWork*: string
notifGoalReached*: string
notifUpdateTitle*: string
notifUpdateBody*: string
breakShort*: string
breakLong*: string
# Boîte de dialogue statistiques.
statsTitle*: string
statsToday*: string
statsWeek*: string
statsStreak*: string
statsGoal*: string
statsDays*: string
statsDayWord*: string
statsTotal*: string
statsNone*: string
statsExportError*: string
# Assistant de premier lancement.
wizardWelcomeTitle*: string
wizardWelcomeBody*: string
wizardLangTitle*: string
wizardLangPrompt*: string
wizardProfileTitle*: string
wizardProfilePrompt*: string
wizardHealthTitle*: string
wizardHealthPrompt*: string
wizardDoneTitle*: string
wizardDoneBody*: string
# Panneau de réglages.
settingsTitle*: string
settingsDurations*: string
settingsWork*: string
settingsShortBreak*: string
settingsLongBreak*: string
settingsCycles*: string
settingsCyclesHint*: string
settingsGeneral*: string
settingsSound*: string
settingsDoNotDisturb*: string
settingsAutoStart*: string
settingsGoal*: string
settingsGoalHint*: string
settingsLanguage*: string
settingsHealth*: string
settingsHealthHint*: string
settingsEditReminders*: string
reminderAdd*: string
reminderEdit*: string
reminderRemove*: string
reminderNewTitle*: string
reminderEditTitle*: string
reminderTextPrompt*: string
settingsProfiles*: string
settingsProfilesHint*: string
settingsSave*: string
settingsCancel*: string
settingsSaved*: string
settingsMinutes*: string
# Libellés génériques.
btnOk*: string
btnCancel*: string
# Nom de profil de repli.
profileFallback*: string
# Rappels santé par défaut (affichés dans l'assistant).
reminderStand*: string
reminderWater*: string
reminderPushups*: string
reminder2020*: string
reminderStretch*: string
# ---------------------------------------------------------------------------
# Bundles français / anglais
# ---------------------------------------------------------------------------
const fr* = Strings(
appName: "TrayTempo",
phaseIdle: "Inactif",
phaseWorking: "Travail",
phaseShortBreak: "Pause courte",
phaseLongBreak: "Pause longue",
phasePaused: "En pause",
tipIdle: "TrayTempo - Inactif",
tipPaused: "TrayTempo - En pause",
menuStart: "Démarrer",
menuResume: "Reprendre",
menuPause: "Mettre en pause",
menuStop: "Arrêter",
menuSkipBreak: "Passer la pause",
menuExtendBreak: "Prolonger la pause (+5 min)",
menuProfiles: "Profils",
menuDoNotDisturb: "Ne pas déranger",
menuAutoStart: "Démarrer avec Windows",
menuFloatingWindow: "Fenêtre flottante",
menuSettings: "Paramètres",
menuStats: "Statistiques",
menuExportCsv: "Exporter les statistiques (CSV)",
menuQuit: "Quitter",
notifWorkDone: "Travail terminé",
notifBreakDone: "Pause terminée",
notifBackToWork: "C'est reparti pour le travail !",
notifGoalReached: "Objectif du jour atteint ! 🎉",
notifUpdateTitle: "Mise à jour disponible",
notifUpdateBody: "La version $1 est disponible.",
breakShort: "courte",
breakLong: "longue",
statsTitle: "Statistiques TrayTempo",
statsToday: "Pomodoros aujourd'hui",
statsWeek: "Pomodoros (7 jours)",
statsStreak: "Série",
statsGoal: "Objectif du jour",
statsDays: "jour(s)",
statsDayWord: "jour",
statsTotal: "Total cumulé",
statsNone: "Aucun pomodoro complété pour l'instant.",
statsExportError: "Impossible d'exporter les statistiques.",
wizardWelcomeTitle: "Bienvenue dans TrayTempo",
wizardWelcomeBody: "Configurons TrayTempo en quelques étapes simples pour que " &
"vous soyez productif tout en prenant soin de votre santé.",
wizardLangTitle: "Langue / Language",
wizardLangPrompt: "Choisissez votre langue :",
wizardProfileTitle: "Profil",
wizardProfilePrompt: "Choisissez votre rythme de travail :",
wizardHealthTitle: "Rappels santé",
wizardHealthPrompt: "Quels rappels souhaitez-vous pendant vos pauses ?",
wizardDoneTitle: "C'est prêt !",
wizardDoneBody: "TrayTempo vit maintenant dans la zone de notification. " &
"Clic droit sur l'icône pour ouvrir le menu, ou Ctrl+Alt+P pour démarrer/mettre en pause.",
settingsTitle: "Paramètres TrayTempo",
settingsDurations: "Durées",
settingsWork: "Travail",
settingsShortBreak: "Pause courte",
settingsLongBreak: "Pause longue",
settingsCycles: "Cycles avant pause longue",
settingsCyclesHint: "Nombre de sessions avant une pause longue.",
settingsGeneral: "Général",
settingsSound: "Son en fin de période",
settingsDoNotDisturb: "Ne pas déranger (suspend les rappels santé)",
settingsAutoStart: "Démarrer avec Windows",
settingsGoal: "Objectif journalier",
settingsGoalHint: "0 = pas d'objectif.",
settingsLanguage: "Langue",
settingsHealth: "Rappels santé",
settingsHealthHint: "Modifiez les rappels affichés pendant les pauses.",
settingsEditReminders: "Modifier les rappels santé",
reminderAdd: "Ajouter",
reminderEdit: "Modifier",
reminderRemove: "Supprimer",
reminderNewTitle: "Nouveau rappel",
reminderEditTitle: "Modifier le rappel",
reminderTextPrompt: "Texte du rappel :",
settingsProfiles: "Profils",
settingsProfilesHint: "Modifiez vos profils dans le fichier de configuration.",
settingsSave: "Enregistrer",
settingsCancel: "Annuler",
settingsSaved: "Paramètres enregistrés.",
settingsMinutes: "min",
btnOk: "OK",
btnCancel: "Annuler",
profileFallback: "Défaut",
reminderStand: "Lève-toi et marche 2 minutes",
reminderWater: "Va boire un verre d'eau",
reminderPushups: "Fais 10 push-ups",
reminder2020: "Règle 20-20-20 : regarde à 20 pieds pendant 20 secondes",
reminderStretch: "Étire ton cou et tes épaules",
)
const en* = Strings(
appName: "TrayTempo",
phaseIdle: "Idle",
phaseWorking: "Working",
phaseShortBreak: "Short break",
phaseLongBreak: "Long break",
phasePaused: "Paused",
tipIdle: "TrayTempo - Idle",
tipPaused: "TrayTempo - Paused",
menuStart: "Start",
menuResume: "Resume",
menuPause: "Pause",
menuStop: "Stop",
menuSkipBreak: "Skip break",
menuExtendBreak: "Extend break (+5 min)",
menuProfiles: "Profiles",
menuDoNotDisturb: "Do not disturb",
menuAutoStart: "Start with Windows",
menuFloatingWindow: "Floating window",
menuSettings: "Settings",
menuStats: "Statistics",
menuExportCsv: "Export statistics (CSV)",
menuQuit: "Quit",
notifWorkDone: "Work session complete",
notifBreakDone: "Break over",
notifBackToWork: "Back to work!",
notifGoalReached: "Daily goal reached! 🎉",
notifUpdateTitle: "Update available",
notifUpdateBody: "Version $1 is available.",
breakShort: "short",
breakLong: "long",
statsTitle: "TrayTempo Statistics",
statsToday: "Pomodoros today",
statsWeek: "Pomodoros (7 days)",
statsStreak: "Streak",
statsGoal: "Daily goal",
statsDays: "day(s)",
statsDayWord: "day",
statsTotal: "Total",
statsNone: "No pomodoros completed yet.",
statsExportError: "Could not export statistics.",
wizardWelcomeTitle: "Welcome to TrayTempo",
wizardWelcomeBody: "Let's set up TrayTempo in a few simple steps so you can " &
"be productive while taking care of your health.",
wizardLangTitle: "Language",
wizardLangPrompt: "Choose your language:",
wizardProfileTitle: "Profile",
wizardProfilePrompt: "Choose your work rhythm:",
wizardHealthTitle: "Health reminders",
wizardHealthPrompt: "Which reminders do you want during your breaks?",
wizardDoneTitle: "You're ready!",
wizardDoneBody: "TrayTempo now lives in the system tray. " &
"Right-click the icon for the menu, or press Ctrl+Alt+P to start/pause.",
settingsTitle: "TrayTempo Settings",
settingsDurations: "Durations",
settingsWork: "Work",
settingsShortBreak: "Short break",
settingsLongBreak: "Long break",
settingsCycles: "Cycles before long break",
settingsCyclesHint: "Number of sessions before a long break.",
settingsGeneral: "General",
settingsSound: "Sound at end of period",
settingsDoNotDisturb: "Do not disturb (suspend health reminders)",
settingsAutoStart: "Start with Windows",
settingsGoal: "Daily goal",
settingsGoalHint: "0 = no goal.",
settingsLanguage: "Language",
settingsHealth: "Health reminders",
settingsHealthHint: "Edit the reminders shown during breaks.",
settingsEditReminders: "Edit health reminders",
reminderAdd: "Add",
reminderEdit: "Edit",
reminderRemove: "Remove",
reminderNewTitle: "New reminder",
reminderEditTitle: "Edit reminder",
reminderTextPrompt: "Reminder text:",
settingsProfiles: "Profiles",
settingsProfilesHint: "Edit your profiles in the configuration file.",
settingsSave: "Save",
settingsCancel: "Cancel",
settingsSaved: "Settings saved.",
settingsMinutes: "min",
btnOk: "OK",
btnCancel: "Cancel",
profileFallback: "Default",
reminderStand: "Stand up and walk for 2 minutes",
reminderWater: "Go drink a glass of water",
reminderPushups: "Do 10 push-ups",
reminder2020: "20-20-20 rule: look 20 feet away for 20 seconds",
reminderStretch: "Stretch your neck and shoulders",
)
# Bundle actif (réaffecté au démarrage selon la langue).
var S* = fr
# ---------------------------------------------------------------------------
# Détection et sélection de la langue
# ---------------------------------------------------------------------------
proc setLang*(l: Lang) =
## Sélectionne le bundle actif.
S = if l == langFr: fr else: en
proc detectSystemLang*(): Lang =
## Retourne la langue système (français si la langue utilisateur est
## française, anglais sinon).
let lid = GetUserDefaultUILanguage()
if (lid and 0x3FF) == 0x0C: langFr else: langEn
proc resolveLang*(cfg: Config): Lang =
## Résout la langue effective à partir de la config ("auto" => système).
case cfg.lang
of "en": langEn
of "fr": langFr
else: detectSystemLang()
proc phaseLabel*(p: TimerPhase): string =
## Libellé localisé d'une phase.
case p
of phIdle: S.phaseIdle
of phWorking: S.phaseWorking
of phShortBreak: S.phaseShortBreak
of phLongBreak: S.phaseLongBreak
of phPaused: S.phasePaused
+57
View File
@@ -0,0 +1,57 @@
## notifications.nim
## =================
## Notifications Windows (toast via NIF_INFO / ballon de notification) et
## rappels santé rotatifs. Le son de fin de période est aussi géré ici.
import winim/lean
import winim/inc/shellapi
import winim/inc/mmsystem
import config
# Identifiant de l'icône tray (doit correspondre à celui de tray.nim).
const TrayIconId = 1
# État de la rotation des rappels : index du dernier rappel affiché.
var lastReminderIndex = -1
proc showToast*(hwnd: HWND, title: string, message: string,
infoFlags: int32 = int32(NIIF_INFO)) =
## Affiche une notification (toast sur Windows 10+, ballon sinon) via
## Shell_NotifyIcon avec NIF_INFO.
var nid: NOTIFYICONDATA
nid.cbSize = sizeof(NOTIFYICONDATA).DWORD
nid.hWnd = hwnd
nid.uID = TrayIconId
nid.uFlags = NIF_INFO
nid.dwInfoFlags = infoFlags or int32(NIIF_RESPECT_QUIET_TIME)
# On tronque pour rester dans les tailles des buffers (63 / 255 caractères).
let t = if title.len > 60: title[0 ..< 60] else: title
let m = if message.len > 240: message[0 ..< 240] else: message
nid.szInfoTitle <<< +$t
nid.szInfo <<< +$m
discard Shell_NotifyIcon(NIM_MODIFY, &nid)
proc playSound*(soundName = "SystemAsterisk"): bool =
## Joue le son système à la fin d'une période (non bloquant). Retourne true
## si le son a été correctement lancé (code de retour de l'API).
PlaySound(soundName, 0, SND_ALIAS or SND_ASYNC) != 0
proc nextHealthReminder*(cfg: Config): string =
## Choisit le prochain rappel santé activé sans répéter le précédent.
## Retourne "" si aucun rappel n'est activé.
var enabledIdx: seq[int] = @[]
for i, r in cfg.healthReminders:
if r.enabled and r.text.len > 0:
enabledIdx.add i
if enabledIdx.len == 0:
return ""
var chosen = enabledIdx[0]
if enabledIdx.len > 1:
for idx in enabledIdx:
if idx != lastReminderIndex:
chosen = idx
break
lastReminderIndex = chosen
return cfg.healthReminders[chosen].text
+105
View File
@@ -0,0 +1,105 @@
## stats.nim
## =========
## Statistiques simples : compteur de pomodoros complétés (jour / semaine)
## et streak de jours consécutifs d'utilisation. Sauvegarde locale en JSON.
import std/[json, os, tables, times, algorithm, strutils]
import config
type
Stats* = object
daily*: Table[string, int] ## date "yyyy-MM-dd" -> nombre de pomodoros.
proc todayStr*(): string =
now().format("yyyy-MM-dd")
proc initStats*(): Stats =
Stats(daily: initTable[string, int]())
proc loadStats*(): Stats =
## Charge les stats depuis le disque (jamais de crash si corrompu).
result = initStats()
try:
let path = statsPath()
if not fileExists(path):
return
let n = parseFile(path)
let d = n.getOrDefault("daily")
if d.kind == JObject:
for k, v in d.fields:
result.daily[k] = v.getInt(0)
except:
discard
proc saveStats*(st: Stats) =
try:
ensureConfigDir()
var n = newJObject()
var daily = newJObject()
for k, v in st.daily:
daily[k] = %v
n["daily"] = daily
writeFile(statsPath(), n.pretty)
except:
discard
proc recordPomodoro*(st: var Stats) =
## Incrémente le compteur du jour courant puis sauvegarde.
let today = todayStr()
st.daily[today] = st.daily.getOrDefault(today, 0) + 1
saveStats(st)
proc completedToday*(st: Stats): int =
st.daily.getOrDefault(todayStr(), 0)
proc goalReached*(st: Stats, goal: int): bool =
## Vrai si l'objectif journalier (>0) vient d'être exactement atteint.
goal > 0 and st.completedToday() == goal
proc completedThisWeek*(st: Stats): int =
## Somme des pomodoros des 7 derniers jours (incluant aujourd'hui).
var d = now()
for i in 0 ..< 7:
result += st.daily.getOrDefault(d.format("yyyy-MM-dd"), 0)
d = d - 1.days
proc streak*(st: Stats): int =
## Nombre de jours consécutifs (jusqu'à aujourd'hui ou hier) avec au moins
## un pomodoro complété. Le jour courant ne casse pas la série tant qu'il
## n'est pas terminé.
var d = now()
if not st.daily.hasKey(todayStr()):
d = d - 1.days
while st.daily.getOrDefault(d.format("yyyy-MM-dd"), 0) > 0:
inc result
d = d - 1.days
proc totalPomodoros*(st: Stats): int =
## Somme de tous les pomodoros enregistrés (historique complet).
for v in st.daily.values:
result += v
proc lastNDays*(st: Stats, n: int): seq[(string, int)] =
## Retourne les n derniers jours (du plus ancien au plus récent) sous forme
## de paires (date, nombre), en incluant les jours sans activité.
var d = now() - (n - 1).days
for i in 0 ..< n:
let key = d.format("yyyy-MM-dd")
result.add (key, st.daily.getOrDefault(key, 0))
d = d + 1.days
proc exportCsv*(st: Stats, path: string): bool =
## Exporte l'historique au format CSV (date,count). Retourne true si réussi.
try:
var lines = @["date,count"]
# Trie les dates par ordre croissant.
var keys: seq[string] = @[]
for k in st.daily.keys:
keys.add k
keys.sort()
for k in keys:
lines.add k & "," & $st.daily[k]
writeFile(path, lines.join("\n"))
result = true
except:
result = false
+109
View File
@@ -0,0 +1,109 @@
## timer.nim
## =========
## Machine à états du minuteur Pomodoro. Aucune dépendance Win32 : le module
## ne fait que compter les secondes et décider des transitions de phase.
##
## Phases : idle, working, shortBreak, longBreak, paused.
import config
type
TimerPhase* = enum
phIdle, phWorking, phShortBreak, phLongBreak, phPaused
## Événement renvoyé par tick() à la fin d'une période.
TickEvent* = enum
evNone ## Rien à signaler.
evWorkCompleted ## Une période de travail vient de se terminer.
evBreakCompleted ## Une pause vient de se terminer.
PomodoroTimer* = object
phase*: TimerPhase
prevPhase*: TimerPhase ## Phase avant mise en pause (pour reprendre).
remainingSecs*: int
cycleCount*: int ## Pomodoros complétés dans le cycle courant.
proc formatTime*(secs: int): string =
## Formate une durée en secondes sous la forme M:SS ou H:MM:SS.
let m = secs div 60
let s = secs mod 60
if m >= 60:
let h = m div 60
let mm = m mod 60
result = $h & ":" & (if mm < 10: "0" else: "") & $mm &
":" & (if s < 10: "0" else: "") & $s
else:
result = $m & ":" & (if s < 10: "0" else: "") & $s
proc initTimer*(): PomodoroTimer =
PomodoroTimer(phase: phIdle, prevPhase: phIdle, remainingSecs: 0, cycleCount: 0)
proc startWork*(t: var PomodoroTimer, p: Profile) =
t.phase = phWorking
t.prevPhase = phWorking
t.remainingSecs = p.workMin * 60
proc startBreak*(t: var PomodoroTimer, p: Profile, longBreak: bool) =
if longBreak:
t.phase = phLongBreak
t.remainingSecs = p.longBreakMin * 60
else:
t.phase = phShortBreak
t.remainingSecs = p.shortBreakMin * 60
t.prevPhase = t.phase
proc pause*(t: var PomodoroTimer) =
if t.phase in {phWorking, phShortBreak, phLongBreak}:
t.prevPhase = t.phase
t.phase = phPaused
proc resume*(t: var PomodoroTimer) =
if t.phase == phPaused:
t.phase = t.prevPhase
proc stop*(t: var PomodoroTimer) =
t.phase = phIdle
t.remainingSecs = 0
t.prevPhase = phIdle
# On remet le cycle à zéro : après un arrêt, le prochain démarrage repart
# d'un cycle complet (sinon la pause longue arrivait au mauvais moment).
t.cycleCount = 0
proc isBreak*(t: PomodoroTimer): bool =
## Vrai si le minuteur est dans une pause (courte ou longue).
t.phase in {phShortBreak, phLongBreak}
proc skipBreak*(t: var PomodoroTimer, p: Profile) =
## Saute la pause courante et repart immédiatement en travail.
if t.isBreak():
t.startWork(p)
proc extendBreak*(t: var PomodoroTimer, minutes: int) =
## Prolonge la pause courante de `minutes` minutes.
if t.isBreak() and minutes > 0:
t.remainingSecs += minutes * 60
proc isRunning*(t: PomodoroTimer): bool =
t.phase in {phWorking, phShortBreak, phLongBreak}
proc tick*(t: var PomodoroTimer, p: Profile): TickEvent =
## Décrémente le temps restant d'une seconde. Retourne un événement quand
## une période se termine et enchaîne automatiquement sur la suivante.
if t.phase notin {phWorking, phShortBreak, phLongBreak}:
return evNone
if t.remainingSecs > 0:
dec t.remainingSecs
if t.remainingSecs > 0:
return evNone
case t.phase
of phWorking:
inc t.cycleCount
let longBreak = (p.cycles > 0) and (t.cycleCount mod p.cycles == 0)
t.startBreak(p, longBreak)
return evWorkCompleted
of phShortBreak, phLongBreak:
t.startWork(p)
return evBreakCompleted
else:
return evNone
+208
View File
@@ -0,0 +1,208 @@
## tray.nim
## ========
## Gestion de l'icône dans la zone de notification (system tray) :
## ajout / mise à jour / suppression de l'icône, icône colorée générée
## dynamiquement selon l'état, et menu contextuel (clic droit).
import winim/lean
import winim/inc/shellapi
import config
import timer
import i18n
const
TrayIconId* = 1
WmTrayIcon* = WM_APP + 1 # message envoyé lors d'un clic sur l'icône
# Identifiants des commandes de menu (reçus via WM_COMMAND).
IdStartPause* = 1001
IdStop* = 1002
IdSettings* = 1003
IdStats* = 1004
IdQuit* = 1005
IdExportCsv* = 1006
IdAutoStart* = 1500
IdDoNotDisturb* = 1501
IdSkipBreak* = 1502
IdExtendBreak* = 1503
IdFloatingWindow* = 1504
IdProfileBase* = 2000 # + index dans cfg.profiles
# ---------------------------------------------------------------------------
# Icône dynamique (un disque coloré généré à la volée via GDI)
# ---------------------------------------------------------------------------
proc createStateIcon(r, g, b: int): HICON =
const S = 32'i32
let screenDC = GetDC(0)
# --- Bitmap couleur (fond noir = transparent, disque de couleur) ---
let colorDC = CreateCompatibleDC(screenDC)
let colorBmp = CreateCompatibleBitmap(screenDC, S, S)
let oldColor = SelectObject(colorDC, colorBmp)
var rc = RECT(left: 0, top: 0, right: S, bottom: S)
var brush = CreateSolidBrush(RGB(0, 0, 0))
FillRect(colorDC, &rc, brush)
DeleteObject(brush)
brush = CreateSolidBrush(RGB(r, g, b))
var oldBrush = SelectObject(colorDC, brush)
Ellipse(colorDC, 3'i32, 3'i32, S - 3'i32, S - 3'i32)
SelectObject(colorDC, oldBrush)
DeleteObject(brush)
SelectObject(colorDC, oldColor)
# --- Masque monochrome (disque blanc opaque, coins transparents) ---
let maskDC = CreateCompatibleDC(screenDC)
let maskBmp = CreateBitmap(S, S, 1, 1, nil)
let oldMask = SelectObject(maskDC, maskBmp)
brush = CreateSolidBrush(RGB(0, 0, 0))
FillRect(maskDC, &rc, brush)
DeleteObject(brush)
brush = CreateSolidBrush(RGB(255, 255, 255))
oldBrush = SelectObject(maskDC, brush)
Ellipse(maskDC, 3'i32, 3'i32, S - 3'i32, S - 3'i32)
SelectObject(maskDC, oldBrush)
DeleteObject(brush)
SelectObject(maskDC, oldMask)
# --- Assemblage de l'icône ---
var ii: ICONINFO
ii.fIcon = TRUE
ii.hbmColor = colorBmp
ii.hbmMask = maskBmp
result = CreateIconIndirect(&ii)
DeleteObject(colorBmp)
DeleteObject(maskBmp)
DeleteDC(colorDC)
DeleteDC(maskDC)
ReleaseDC(0, screenDC)
# Cache des icônes (une par phase) pour éviter de les recréer à chaque tick.
var cachedIcons: array[TimerPhase, HICON]
var iconsReady = false
proc phaseIcon*(phase: TimerPhase): HICON =
if not iconsReady:
cachedIcons[phIdle] = createStateIcon(128, 128, 128) # gris
cachedIcons[phWorking] = createStateIcon(214, 69, 65) # rouge tomate
cachedIcons[phShortBreak] = createStateIcon(80, 180, 90) # vert
cachedIcons[phLongBreak] = createStateIcon(70, 130, 220) # bleu
cachedIcons[phPaused] = createStateIcon(240, 180, 50) # ambre
iconsReady = true
result = cachedIcons[phase]
# ---------------------------------------------------------------------------
# Icône dans le tray
# ---------------------------------------------------------------------------
proc addTrayIcon*(hwnd: HWND, icon: HICON, tip: string): bool =
var nid: NOTIFYICONDATA
nid.cbSize = sizeof(NOTIFYICONDATA).DWORD
nid.hWnd = hwnd
nid.uID = TrayIconId
nid.uFlags = NIF_MESSAGE or NIF_ICON or NIF_TIP
nid.uCallbackMessage = WmTrayIcon
nid.hIcon = icon
nid.szTip <<< +$tip
result = Shell_NotifyIcon(NIM_ADD, &nid) != 0
proc updateTrayIcon*(hwnd: HWND, icon: HICON, tip: string) =
var nid: NOTIFYICONDATA
nid.cbSize = sizeof(NOTIFYICONDATA).DWORD
nid.hWnd = hwnd
nid.uID = TrayIconId
nid.uFlags = NIF_ICON or NIF_TIP
nid.hIcon = icon
nid.szTip <<< +$tip
discard Shell_NotifyIcon(NIM_MODIFY, &nid)
proc removeTrayIcon*(hwnd: HWND) =
var nid: NOTIFYICONDATA
nid.cbSize = sizeof(NOTIFYICONDATA).DWORD
nid.hWnd = hwnd
nid.uID = TrayIconId
discard Shell_NotifyIcon(NIM_DELETE, &nid)
# ---------------------------------------------------------------------------
# Menu contextuel
# ---------------------------------------------------------------------------
proc tooltipText*(cfg: Config, t: PomodoroTimer): string =
case t.phase
of phIdle: S.tipIdle
of phPaused: S.tipPaused & " (" & formatTime(t.remainingSecs) & ")"
else: S.appName & " - " & phaseLabel(t.phase) & ": " & formatTime(t.remainingSecs)
# Constantes de flags de menu typées `UINT` (int32 dans winim) pour éviter les
# conversions implicites int -> int32 (source d'erreurs de compilation).
const
mfStr = 0x00000000'i32
mfGray = 0x00000001'i32
mfChk = 0x00000008'i32
mfSep = 0x00000800'i32
mfPop = 0x00000010'i32
proc showTrayMenu*(hwnd: HWND, cfg: Config, t: PomodoroTimer) =
## Construit et affiche le menu contextuel. Les commandes sont ensuite
## reçues par la fenêtre via WM_COMMAND.
let menu = CreatePopupMenu()
# Démarrer / Reprendre / Pause selon l'état.
let toggleLabel =
case t.phase
of phIdle: S.menuStart
of phPaused: S.menuResume
else: S.menuPause
AppendMenu(menu, mfStr, int64(IdStartPause), toggleLabel)
# Arrêter (désactivé si rien ne tourne).
let stopFlags = if t.phase == phIdle: mfStr or mfGray else: mfStr
AppendMenu(menu, stopFlags, int64(IdStop), S.menuStop)
# Sauter / prolonger la pause (uniquement pendant une pause).
if t.isBreak():
AppendMenu(menu, mfStr, int64(IdSkipBreak), S.menuSkipBreak)
AppendMenu(menu, mfStr, int64(IdExtendBreak), S.menuExtendBreak)
AppendMenu(menu, mfSep, 0, nil)
# Sous-menu profils.
let profilesMenu = CreatePopupMenu()
if cfg.profiles.len > 0:
for i, p in cfg.profiles:
var flags = mfStr
if p.name == cfg.activeProfile:
flags = flags or mfChk
AppendMenu(profilesMenu, flags, int64(IdProfileBase + i), p.name)
AppendMenu(menu, mfPop, cast[UINT_PTR](profilesMenu), S.menuProfiles)
AppendMenu(menu, mfSep, 0, nil)
# Options pratiques (cochées si actives).
AppendMenu(menu, if cfg.doNotDisturb: mfStr or mfChk else: mfStr,
int64(IdDoNotDisturb), S.menuDoNotDisturb)
AppendMenu(menu, if cfg.autoStart: mfStr or mfChk else: mfStr,
int64(IdAutoStart), S.menuAutoStart)
AppendMenu(menu, if cfg.floatingWindow: mfStr or mfChk else: mfStr,
int64(IdFloatingWindow), S.menuFloatingWindow)
AppendMenu(menu, mfSep, 0, nil)
AppendMenu(menu, mfStr, int64(IdSettings), S.menuSettings)
AppendMenu(menu, mfStr, int64(IdStats), S.menuStats)
AppendMenu(menu, mfStr, int64(IdExportCsv), S.menuExportCsv)
AppendMenu(menu, mfSep, 0, nil)
AppendMenu(menu, mfStr, int64(IdQuit), S.menuQuit)
var pt: POINT
GetCursorPos(&pt)
SetForegroundWindow(hwnd) # requis pour que le menu se referme proprement
discard TrackPopupMenu(menu, TPM_RIGHTBUTTON or TPM_BOTTOMALIGN or TPM_LEFTALIGN,
pt.x, pt.y, 0, hwnd, nil)
# Le sous-menu profils est détruit avec le menu parent par DestroyMenu.
DestroyMenu(menu)
PostMessage(hwnd, WM_NULL, 0, 0)
+297
View File
@@ -0,0 +1,297 @@
## traytempo.nim
## =============
## Point d'entrée de l'application. Crée une fenêtre cachée, enregistre
## l'icône tray, le minuteur (WM_TIMER) et le raccourci global, puis exécute
## la boucle de messages Windows.
##
## Compilation :
## nim c -d:release --app:gui src/traytempo.nim
import std/os
import std/typedthreads
import std/strutils
import winim/lean
import winim/inc/shellapi
import config
import timer
import tray
import notifications
import stats
import i18n
import dialogs
import updater
import floating
const
TimerId = 1'i64
HotkeyId = 1'i32
SingleInstanceMutexName = "TrayTempo_SingleInstance_Mutex"
WmUpdateAvailable = WM_APP + 2
# ---------------------------------------------------------------------------
# État global de l'application
# ---------------------------------------------------------------------------
var
gCfg: Config
gTimer: PomodoroTimer
gStats: Stats
gHwnd: HWND
gUpdateLatest: array[32, char] ## Version distante (buffer non-GC, partagé
## entre le thread de mise à jour et la boucle
## de messages).
# ---------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------
proc floatingText(): string =
## Texte affiché dans la fenêtre flottante (phase + temps restant).
case gTimer.phase
of phIdle: S.phaseIdle
of phPaused: S.phasePaused & " " & formatTime(gTimer.remainingSecs)
else: phaseLabel(gTimer.phase) & " " & formatTime(gTimer.remainingSecs)
proc refreshTray() =
## Met à jour l'icône et le tooltip selon l'état courant.
updateTrayIcon(gHwnd, phaseIcon(gTimer.phase), tooltipText(gCfg, gTimer))
if floatingVisible():
updateFloating(floatingText())
proc updateCheckThread() {.thread.} =
## Vérifie les mises à jour en arrière-plan et prévient la fenêtre principale.
let latest = fetchLatestVersion()
if latest.len > 0 and isNewer(AppVersion, latest):
copyMem(addr gUpdateLatest[0], latest.cstring, min(latest.len + 1, 32))
discard PostMessage(gHwnd, WmUpdateAvailable, 0, 0)
proc applyHotkey() =
## (Ré)enregistre le raccourci global selon la configuration.
discard UnregisterHotKey(gHwnd, HotkeyId)
if gCfg.hotkeyVk != 0:
discard RegisterHotKey(gHwnd, HotkeyId, UINT(gCfg.hotkeyModifiers),
UINT(gCfg.hotkeyVk))
proc toggleStartPause() =
case gTimer.phase
of phIdle: gTimer.startWork(getActiveProfile(gCfg))
of phPaused: gTimer.resume()
else: gTimer.pause()
refreshTray()
proc stopTimer() =
gTimer.stop()
refreshTray()
proc quitApp() =
saveConfig(gCfg)
hideFloating()
removeTrayIcon(gHwnd)
UnregisterHotKey(gHwnd, HotkeyId)
DestroyWindow(gHwnd)
proc openSettings() =
## Ouvre le panneau de réglages graphique.
if showSettingsDialog(gHwnd, gCfg):
saveConfig(gCfg)
setLang(resolveLang(gCfg))
discard setAutostart(gCfg.autoStart)
applyHotkey()
refreshTray()
showToast(gHwnd, S.appName, S.settingsSaved)
proc showStatsDialog() =
dialogs.showStatsDialog(gHwnd, gCfg, gStats)
proc exportStatsCsv() =
## Exporte l'historique en CSV dans le répertoire de configuration.
let path = configDir() / "stats_export.csv"
if gStats.exportCsv(path):
discard ShellExecuteW(0, "open", path, nil, nil, SW_SHOWNORMAL)
else:
discard MessageBox(gHwnd, S.statsExportError, S.appName, MB_ICONERROR)
proc onWorkCompleted() =
## Fin d'une période de travail : comptabilise, son, notification et
## rappel santé (si autorisé).
gStats.recordPomodoro()
if gCfg.soundEnabled:
discard playSound(gCfg.soundName)
var msg = S.notifWorkDone & " — " &
(if gTimer.phase == phLongBreak: S.breakLong else: S.breakShort) &
" (" & formatTime(gTimer.remainingSecs) & ")"
if not gCfg.doNotDisturb:
let reminder = nextHealthReminder(gCfg)
if reminder.len > 0:
msg = msg & "\n" & reminder
showToast(gHwnd, S.notifWorkDone, msg)
# Objectif journalier atteint (notification unique au franchissement).
if gStats.goalReached(gCfg.dailyGoal):
showToast(gHwnd, S.appName, S.notifGoalReached)
proc onBreakCompleted() =
if gCfg.soundEnabled:
discard playSound(gCfg.soundName)
showToast(gHwnd, S.notifBreakDone, S.notifBackToWork)
proc onTick() =
## Appelé chaque seconde (WM_TIMER).
case gTimer.tick(getActiveProfile(gCfg))
of evWorkCompleted: onWorkCompleted()
of evBreakCompleted: onBreakCompleted()
of evNone: discard
refreshTray()
proc skipBreak() =
gTimer.skipBreak(getActiveProfile(gCfg))
refreshTray()
proc extendBreak() =
gTimer.extendBreak(5)
refreshTray()
proc handleCommand(cmd: int) =
case cmd
of IdStartPause: toggleStartPause()
of IdStop: stopTimer()
of IdSkipBreak: skipBreak()
of IdExtendBreak: extendBreak()
of IdSettings: openSettings()
of IdStats: showStatsDialog()
of IdExportCsv: exportStatsCsv()
of IdAutoStart:
gCfg.autoStart = not gCfg.autoStart
discard setAutostart(gCfg.autoStart)
saveConfig(gCfg)
of IdDoNotDisturb:
gCfg.doNotDisturb = not gCfg.doNotDisturb
saveConfig(gCfg)
of IdFloatingWindow:
gCfg.floatingWindow = not gCfg.floatingWindow
if gCfg.floatingWindow:
showFloating()
updateFloating(floatingText())
else:
hideFloating()
saveConfig(gCfg)
of IdQuit: quitApp()
else:
# Sélection d'un profil (le changement s'applique à la prochaine période).
let idx = cmd - IdProfileBase
if idx >= 0 and idx < gCfg.profiles.len:
gCfg.activeProfile = gCfg.profiles[idx].name
saveConfig(gCfg)
refreshTray()
# ---------------------------------------------------------------------------
# Procédure de fenêtre
# ---------------------------------------------------------------------------
proc wndProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
case msg
of WmTrayIcon:
let mouseMsg = int(lParam and 0xFFFF)
if mouseMsg == WM_RBUTTONUP or mouseMsg == WM_CONTEXTMENU:
showTrayMenu(hwnd, gCfg, gTimer)
elif mouseMsg == WM_LBUTTONDBLCLK:
toggleStartPause()
return 0
of WM_COMMAND:
handleCommand(int(wParam))
return 0
of WM_TIMER:
if wParam == TimerId:
onTick()
return 0
of WM_HOTKEY:
if wParam == int64(HotkeyId):
toggleStartPause()
return 0
of WmUpdateAvailable:
let ver = $cast[cstring](addr gUpdateLatest[0])
showToast(gHwnd, S.notifUpdateTitle, S.notifUpdateBody % [ver])
return 0
of WM_DESTROY:
PostQuitMessage(0)
return 0
else:
discard
return DefWindowProc(hwnd, msg, wParam, lParam)
# ---------------------------------------------------------------------------
# Point d'entrée
# ---------------------------------------------------------------------------
proc isAlreadyRunning(): bool =
## Garde mono-instance via un mutex nommé.
acquireSingleInstance(SingleInstanceMutexName)
proc main() =
if isAlreadyRunning():
discard MessageBox(0, "TrayTempo est déjà en cours d'exécution.",
"TrayTempo", MB_OK or MB_ICONINFORMATION)
return
gCfg = loadConfig()
gStats = loadStats()
gTimer = initTimer()
# Premier lancement : assistant de configuration guidée.
let firstRun = not fileExists(configPath())
if firstRun:
setLang(detectSystemLang())
var cfg = defaultConfig(resolveLang(gCfg))
cfg.lang = "auto"
if showFirstRunWizard(cfg):
gCfg = cfg
saveConfig(gCfg)
setLang(resolveLang(gCfg))
let hInstance = GetModuleHandleW(nil)
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](wndProc)
wc.hInstance = hInstance
wc.lpszClassName = L"TrayTempoWindowClass"
wc.hIcon = phaseIcon(phIdle)
if RegisterClass(&wc) == 0:
# La classe peut déjà être enregistrée ; on poursuit quand même.
discard
gHwnd = CreateWindowExW(0, L"TrayTempoWindowClass", L"TrayTempo",
WS_OVERLAPPED, 0, 0, 0, 0, 0, 0, hInstance, nil)
if gHwnd == 0:
discard MessageBox(0, "Impossible de créer la fenêtre de l'application.",
"TrayTempo", MB_ICONERROR)
return
# Icône dans le tray.
discard addTrayIcon(gHwnd, phaseIcon(gTimer.phase), tooltipText(gCfg, gTimer))
# Minuteur (1 tick par seconde).
discard SetTimer(gHwnd, TimerId, 1000, nil)
# Raccourci global configurable.
applyHotkey()
# Fenêtre flottante (si activée dans la configuration).
if gCfg.floatingWindow:
showFloating()
updateFloating(floatingText())
# Vérification de mise à jour en arrière-plan (désactivée si UpdateUrl vide).
if UpdateUrl.len > 0:
var updateThread: Thread[void]
createThread(updateThread, updateCheckThread)
# Boucle de messages.
var msg: MSG
while GetMessage(&msg, 0, 0, 0) != 0:
TranslateMessage(&msg)
DispatchMessage(&msg)
when isMainModule:
main()
+46
View File
@@ -0,0 +1,46 @@
## updater.nim
## ============
## Vérification de mise à jour. Deux fonctions pures (`parseVersion`,
## `isNewer`) sont testables sans réseau ; `fetchLatestVersion` interroge une
## URL distante (endpoint qui renvoie une version "x.y.z" en texte brut).
##
## L'URL est configurable. Laissez `UpdateUrl` vide pour désactiver la
## vérification automatique.
import std/[httpclient, strutils]
# URL du fichier de version (texte brut, ex. "0.3.0"). Mettre à "" pour
# désactiver la vérification de mise à jour.
const UpdateUrl* = ""
proc parseVersion*(v: string): seq[int] =
## Découpe "1.2.3" en [1, 2, 3]. Les composants non numériques valent 0.
for part in v.strip().split('.'):
try:
result.add parseInt(part)
except ValueError:
result.add 0
proc isNewer*(current, latest: string): bool =
## Retourne true si `latest` est strictement plus récente que `current`.
let c = parseVersion(current)
let l = parseVersion(latest)
let n = max(c.len, l.len)
for i in 0 ..< n:
let cv = if i < c.len: c[i] else: 0
let lv = if i < l.len: l[i] else: 0
if lv > cv: return true
if lv < cv: return false
false
proc fetchLatestVersion*(url = UpdateUrl): string =
## Récupère la dernière version depuis `url`. Retourne "" en cas d'échec
## (réseau, timeout, URL vide).
if url.len == 0:
return ""
try:
let client = newHttpClient(timeout = 5000)
defer: client.close()
result = client.getContent(url).strip()
except:
result = ""