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
+12
View File
@@ -0,0 +1,12 @@
# Binaires compilés
*.exe
# Cache de compilation Nim
nimcache/
# Artefacts de build / release
build/
portable/
# Fichiers de configuration/runtime locaux
*.json.bak
+172
View File
@@ -0,0 +1,172 @@
# TrayTempo
Timer **Pomodoro nouvelle génération** pour Windows, écrit entièrement en
**Nim**. Il vit discrètement dans la zone de notification (system tray) et
prend soin de votre santé autant que de votre productivité, dans une interface
**simple et intuitive** pour tous : non-techniciens, étudiants, développeurs.
## Fonctionnalités
- **Icône tray** : démarre minimisé, menu contextuel complet, icône colorée
dynamique selon l'état (travail / pause courte / pause longue / en pause),
tooltip avec le temps restant.
- **Assistant de premier lancement** : en quelques étapes (langue, profil,
rappels santé), l'application est prête sans toucher à un fichier.
- **Panneau de réglages graphique** : durées, objectif journalier, son, mode
« Ne pas déranger », démarrage avec Windows, langue, rappels santé. Plus
besoin d'éditer le JSON à la main.
- **Internationalisation** : français et anglais (détection automatique de la
langue système ou choix manuel).
- **Timer personnalisable** : durées de travail / pause courte / pause longue,
nombre de cycles avant pause longue, et **profils** (« Classique »,
« Deep Work » 50/10, « Sprint » 15/3).
- **Contrôle des pauses** : sauter la pause, la prolonger de 5 minutes.
- **Objectif journalier** : définissez un nombre de pomodoros par jour et
recevez une notification quand il est atteint.
- **Rappels santé intelligents** (le différenciateur) : notifications rotatives
pendant les pauses (« Lève-toi et marche », « Bois un verre d'eau »,
« Fais 10 push-ups », règle 20-20-20, étirements…), activables/désactivables
individuellement, sans répéter deux fois le même rappel.
- **Notifications Windows natives** (toast via `Shell_NotifyIcon` + `NIF_INFO`)
et son optionnel en fin de période.
- **Statistiques enrichies** : pomodoros complétés (jour / semaine / total),
streak de jours consécutifs, historique des 7 derniers jours, **export CSV**.
- **Raccourci global** `Ctrl+Alt+P` (configurable) pour démarrer/mettre en
pause, sans toucher la souris.
- **Fenêtre flottante** : un petit panneau toujours au-dessus, draggable, qui
affiche la phase et le temps restant en grand (pratique et lisible d'un coup
d'œil).
- **Mono-instance** : lancer le programme une seconde fois ne crée pas de
doublon.
- **Mode portable** : placez un fichier vide `portable` à côté de l'exécutable
et la configuration est stockée au même endroit (pratique sur clé USB).
- **Mise à jour automatique** (optionnelle) : vérification en arrière-plan,
notification si une version plus récente est disponible.
## Prérequis
- [Nim](https://nim-lang.org) ≥ 2.0 (testé avec 2.2.10)
- La bibliothèque [winim](https://github.com/khchen/winim) :
```bash
nimble install winim
```
## Compilation
```bash
nim c -d:release --app:gui src/traytempo.nim
```
Le binaire résultant `src/traytempo.exe` est autonome (aucune dépendance
externe à installer) et pèse moins de 1 Mo.
> **Note** : à l'édition de liens, `winim` injecte son propre fichier de
> ressources (styles visuels / manifeste), ce qui peut produire l'avertissement
> bénin `ld.exe: .rsrc merge failure: multiple non-default manifests`. Il est
> sans effet sur le fonctionnement. Pour le supprimer, compilez avec
> `-d:noRes` (vous perdrez alors les styles visuels des boîtes de dialogue).
## Distribution
- **Binaire autonome** : `src\traytempo.exe` (ou `build\traytempo.exe`).
- **Archive portable** : lancez [`release.cmd`](release.cmd) pour compiler et
créer `build\TrayTempo-portable.zip` (version prête à copier sur une clé USB).
- **Installeur** : un script [Inno Setup](installer/traytempo.iss) est fourni.
Compilez-le avec [Inno Setup 6](https://jrsoftware.org/isinfo.php).
## Configuration
Au premier lancement (et à la sortie), l'application crée :
```
%APPDATA%\TrayTempo\
├── config.json # réglages, profils, rappels santé
└── stats.json # statistiques
```
En **mode portable**, ces fichiers sont créés à côté de l'exécutable.
Un exemple de configuration est fourni dans
[`config.example.json`](config.example.json).
### Structure de `config.json`
| Clé | Type | Description |
|-------------------------|---------|--------------------------------------------------|
| `workMin` | int | Durée de travail (défaut 25) |
| `shortBreakMin` | int | Pause courte (défaut 5) |
| `longBreakMin` | int | Pause longue (défaut 15) |
| `cyclesBeforeLongBreak` | int | Cycles avant pause longue (défaut 4) |
| `soundEnabled` | bool | Son en fin de période |
| `doNotDisturb` | bool | Mode « Ne pas déranger » |
| `autoStart` | bool | Démarrage avec Windows |
| `activeProfile` | string | Nom du profil actif |
| `lang` | string | `"auto"`, `"fr"` ou `"en"` |
| `dailyGoal` | int | Objectif de pomodoros par jour (0 = aucun) |
| `hotkeyModifiers` | int | Modificateurs du raccourci global (Ctrl+Alt = 3) |
| `hotkeyVk` | int | Code de touche virtuelle du raccourci (`P` = 80) |
| `soundName` | string | Alias sonore système (fin de période) |
| `floatingWindow` | bool | Affiche la fenêtre flottante |
| `profiles` | array | Liste des profils (`name`, durées, `cycles`) |
| `healthReminders` | array | Rappels santé (`text`, `enabled`) |
## Architecture du code
```
src/
├── traytempo.nim # point d'entrée : fenêtre cachée, boucle de messages,
│ # WM_TIMER, raccourci global, mono-instance, câblage
├── config.nim # Config/Profile/HealthReminder/Lang, JSON, registre (Run)
├── timer.nim # machine à états Pomodoro (aucune dépendance Win32)
├── tray.nim # Shell_NotifyIcon, icône dynamique (GDI), menu contextuel
├── notifications.nim # toast (NIF_INFO), son, rotation des rappels santé
├── stats.nim # compteurs jour/semaine/total, streak, CSV, JSON
├── i18n.nim # chaînes localisées (français / anglais), détection langue
├── dialogs.nim # réglages, rappels santé, assistant, statistiques (Win32)
├── floating.nim # fenêtre flottante compacte (toujours au-dessus)
└── updater.nim # comparaison de versions + récupération distante
```
Séparation des responsabilités : `timer`, `stats` et `updater` sont purs
(aucune dépendance à l'API Win32), ce qui les rend testables ; `config`,
`tray`, `notifications`, `dialogs` et `traytempo` gèrent l'interaction avec
Windows.
## Raccourcis
| Action | Raccourci |
|-------------------------------|----------------|
| Démarrer / mettre en pause | `Ctrl+Alt+P` |
| Menu contextuel | Clic droit |
| Démarrer / mettre en pause | Double-clic |
## Tests
Les modules purs (`timer`, `stats`, `config`, `notifications`, `updater`) sont
testés unitairement, ainsi que l'intégration Win32 (`tray`). Un exécutable
unique les lance toutes et affiche un résumé :
```bash
run_tests.cmd
```
Ou individuellement :
```bash
nim c -r tests/test_timer.nim
nim c -r tests/test_stats.nim
nim c -r tests/test_config.nim
nim c -r tests/test_notifications.nim
nim c -r tests/test_tray_integration.nim
nim c -r tests/test_updater.nim
nim c -r tests/test_floating.nim
nim c -r tests/test_dialogs.nim
```
La correspondance exigence → contrôle → résultat est détaillée dans
[`VERIFICATION.md`](VERIFICATION.md).
## Licence
MIT
+80
View File
@@ -0,0 +1,80 @@
# Vérification TrayTempo
Table de correspondance entre chaque exigence de la spécification, le contrôle
concret effectué, et le résultat observé.
## Fonctionnalités principales
| # | Exigence | Contrôle concret | Résultat observé |
|---|----------|------------------|------------------|
| 1 | Icône system tray (démarre minimisé, pas de fenêtre intrusive) | `tests/test_tray_integration.nim` : `addTrayIcon` → `Shell_NotifyIcon(NIM_ADD)` retourne `true` | **OK** (API réussit, icône ajoutée) |
| 1a | Menu contextuel (Démarrer/Pause/Arrêter, Profils, Paramètres, Statistiques, Quitter) | Construction du menu dans `tray.nim` (`CreatePopupMenu`/`AppendMenu`/`TrackPopupMenu`) | Compile ; affichage non inspecté visuellement |
| 1b | Icône dynamique selon l'état (travail/pause courte/pause longue/pause) | `tests/test_tray_integration.nim` : `phaseIcon` retourne un `HICON` non nul pour les 5 phases | **OK** (5 icônes GDI générées) |
| 1c | Tooltip temps restant | `updateTrayIcon(NIF_TIP)` appelé dans le test d'intégration | **OK** (NIM_MODIFY sans erreur) |
| 2 | Durées configurables + profils + sauvegarde `%APPDATA%\TrayTempo` | `tests/test_timer.nim` (17 tests) + `tests/test_config.nim` (round-trip JSON, accents UTF-8, champs manquants) | 17 + 9 tests **OK** |
| 3 | Rappels santé configurables, rotation sans répétition | `tests/test_notifications.nim` (5 tests) | 5 tests **OK** |
| 4 | Notifications natives (toast / `NIF_INFO`) + son optionnel | `tests/test_tray_integration.nim` : `showToast` (NIF_INFO) sans erreur + `playSound("SystemAsterisk")` retourne `true` | **OK** (API ; rendu toast et son audible non observés) |
| 5 | Statistiques (jour/semaine/total, streak, historique, CSV) | `tests/test_stats.nim` (10 tests) | 10 tests **OK** |
| 6 | Démarrage auto (registre `Run`) | `tests/test_config.nim` « setAutostart toggles the Run key and cleans up » | **OK** |
| 6b | Mode « Ne pas déranger » | champ `doNotDisturb` + branche dans `onWorkCompleted` | Compile ; logique vérifiée par relecture |
| 6c | Raccourci global (Ctrl+Alt+P, configurable) | `tests/test_tray_integration.nim` : `RegisterHotKey`/`UnregisterHotKey` retournent non-zéro | **OK** |
## Fonctionnalités ajoutées (v0.2.0)
| # | Exigence | Contrôle concret | Résultat observé |
|---|----------|------------------|------------------|
| 7 | Mono-instance (mutex nommé) | `config.nim` `acquireSingleInstance` ; `tests/test_config.nim` « acquireSingleInstance detects a second lock » | **OK** (première acquisition → false, seconde → true) |
| 8 | Fix du bug `cycleCount` (reset sur `stop`) | `tests/test_timer.nim` « stop resets cycleCount » | **OK** |
| 9 | Sauter / prolonger une pause | `tests/test_timer.nim` (skipBreak, extendBreak, isBreak) | 4 tests **OK** |
| 10 | Internationalisation (fr/en) | `i18n.nim` : deux bundles `Strings`, détection langue système ; `tests/test_config.nim` « English defaults » | **OK** |
| 11 | Panneau de réglages | `tests/test_dialogs.nim` : `buildSettingsDialog` crée une fenêtre et ses contrôles | **OK** (fenêtre + contrôles créés) |
| 12 | Objectif journalier | `stats.nim` `goalReached` ; `tests/test_stats.nim` « goalReached is true only when the goal is exactly met » | **OK** |
| 13 | Hotkey et son configurables | champs `hotkeyModifiers`/`hotkeyVk`/`soundName` ; `tests/test_config.nim` round-trip | **OK** |
| 14 | Mode portable | `configDir()` ; `tests/test_config.nim` « configDir returns exe dir in portable mode » | **OK** (mode portable → exe dir, sinon APPDATA) |
| 15 | Mise à jour (comparaison de versions) | `tests/test_updater.nim` (5 tests) | 5 tests **OK** |
| 16 | Fenêtre flottante (toujours au-dessus, texte agrandi) | `tests/test_floating.nim` (show/update/hide une vraie fenêtre Win32) + `floatingWindow` round-trip | **OK** (fenêtre créée/mise à jour/détruite sans erreur) |
## Contraintes techniques
| Contrainte | Contrôle concret | Résultat observé |
|------------|------------------|------------------|
| Nim ≥ 2.0 | Compilé avec Nim 2.2.10 | OK |
| winim | `nimble install winim` (3.9.4) | OK |
| Binaire unique < 5 Mo | `dir src\traytempo.exe` | ~594 Ko |
| `nim c -d:release --app:gui` | commande exacte exécutée | compile **SuccessX** |
| Modules (`tray/timer/notifications/config/stats/i18n/dialogs/floating/updater`) | `ls src/` | 10 modules présents |
| Boucle messages + `SetTimer`/`WM_TIMER` | `GetMessage` loop + `SetTimer`/`WM_TIMER` dans `traytempo.nim` | Compile + timer décrémenté (testé via `tick`) |
## Livrables
| Livrable | Contrôle | Résultat |
|----------|----------|----------|
| Structure + `traytempo.nimble` | fichier présent, `requires winim` | OK |
| Code source commenté (tous modules) | en-têtes `##` de module + commentaires | OK |
| README + instructions compilation | `README.md` (compile, config, archi, tests, distribution) | OK |
| Config exemple + profils par défaut | `config.example.json` (3 profils, 5 rappels, nouveaux champs) | OK |
| Script de release (portable) | `release.cmd` exécuté | **OK** (zip créé) |
| Script d'installation | `installer/traytempo.iss` présent | OK (nécessite Inno Setup pour compiler) |
## Limites connues de la vérification
- Les **codes de retour** des API Win32 (ajout/suppression d'icône tray,
génération d'icônes, notification, hotkey, son via `PlaySound`) sont vérifiés
via `tests/test_tray_integration.nim`. Seul le **rendu visuel** (aspect de
l'icône, contenu exact du toast, menu déroulant, boîtes de dialogue) et le
**son audible** restent non observables dans l'environnement automatisé.
- La **mise à jour automatique** n'est pas testée en conditions réelles
(aucun serveur de version configuré) ; la comparaison de versions est testée
unitairement.
- L'avertissement d'édition de liens `multiple non-default manifests` est
bénin (manifeste injecté par `winim`, documenté dans le README).
## Commande de re-vérification
```bash
run_tests.cmd
```
(ou `nim c -d:release --app:gui src/traytempo.nim` pour le binaire)
52 tests au total, tous verts (8 suites).
+28
View File
@@ -0,0 +1,28 @@
{
"workMin": 25,
"shortBreakMin": 5,
"longBreakMin": 15,
"cyclesBeforeLongBreak": 4,
"soundEnabled": true,
"doNotDisturb": false,
"autoStart": false,
"activeProfile": "Classique",
"lang": "auto",
"dailyGoal": 8,
"hotkeyModifiers": 3,
"hotkeyVk": 80,
"soundName": "SystemAsterisk",
"floatingWindow": false,
"profiles": [
{ "name": "Classique", "workMin": 25, "shortBreakMin": 5, "longBreakMin": 15, "cycles": 4 },
{ "name": "Deep Work", "workMin": 50, "shortBreakMin": 10, "longBreakMin": 20, "cycles": 4 },
{ "name": "Sprint", "workMin": 15, "shortBreakMin": 3, "longBreakMin": 10, "cycles": 6 }
],
"healthReminders": [
{ "text": "Lève-toi et marche 2 minutes", "enabled": true },
{ "text": "Va boire un verre d'eau", "enabled": true },
{ "text": "Fais 10 push-ups", "enabled": true },
{ "text": "Règle 20-20-20 : regarde à 20 pieds pendant 20 secondes", "enabled": true },
{ "text": "Étire ton cou et tes épaules", "enabled": true }
]
}
+45
View File
@@ -0,0 +1,45 @@
; =====================================================================
; TrayTempo - Script d'installation Inno Setup
; Compiler avec Inno Setup 6 (https://jrsoftware.org/isinfo.php).
; Placez le binaire compilé `traytempo.exe` (build\traytempo.exe) dans le
; même dossier que ce script, puis compilez avec le compilateur ISCC.
; =====================================================================
#define MyAppName "TrayTempo"
#define MyAppVersion "0.2.0"
#define MyAppPublisher "TrayTempo"
#define MyAppExeName "traytempo.exe"
[Setup]
AppId={{7E5A3C2B-9F1D-4E6A-8B2C-1A3D5F7E9B0C}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
OutputDir=..\build
OutputBaseFilename=TrayTempo-Setup-{#MyAppVersion}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
; L'application vit dans la zone de notification : pas de raccourci intrusif.
PrivilegesRequired=lowest
ArchitecturesInstallIn64BitMode=x64
[Languages]
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Créer un raccourci sur le bureau"; GroupDescription: "Raccourcis :"
[Files]
Source: "traytempo.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "Lancer {#MyAppName}"; Flags: nowait postinstall skipifsilent
+50
View File
@@ -0,0 +1,50 @@
@echo off
rem =====================================================================
rem Construit le binaire de release et prépare les artefacts de
rem distribution : exécutable autonome + archive portable (zip).
rem =====================================================================
setlocal enabledelayedexpansion
cd /d "%~dp0"
set OUT=build
if not exist "%OUT%" mkdir "%OUT%"
echo.
echo === Compilation (release) ===
nim c -d:release --app:gui --hints:off -o:"%OUT%\traytempo.exe" src\traytempo.nim
if errorlevel 1 (
echo [FAIL] compilation
exit /b 1
)
echo.
echo === Taille du binaire ===
dir "%OUT%\traytempo.exe" | findstr /i "traytempo.exe"
echo.
echo === Preparation de l'archive portable ===
set PORTABLE=%OUT%\portable
if exist "%PORTABLE%" rmdir /s /q "%PORTABLE%"
mkdir "%PORTABLE%"
copy /y "%OUT%\traytempo.exe" "%PORTABLE%\" >nul
copy /y "config.example.json" "%PORTABLE%\" >nul
copy /y "README.md" "%PORTABLE%\" >nul
rem Le fichier `portable` (vide) indique a l'application de stocker sa
rem configuration a cote de l'executable au lieu de %%APPDATA%%.
type nul > "%PORTABLE%\portable"
powershell -NoProfile -Command "Compress-Archive -Path '%PORTABLE%\*' -DestinationPath '%OUT%\TrayTempo-portable.zip' -Force"
if errorlevel 1 (
echo [FAIL] creation de l'archive
exit /b 1
)
echo.
echo === Artefacts ===
dir /b "%OUT%"
echo.
echo Termine. L'executable autonome est dans build\traytempo.exe
echo et l'archive portable dans build\TrayTempo-portable.zip
exit /b 0
+32
View File
@@ -0,0 +1,32 @@
@echo off
rem =====================================================================
rem Exécute toutes les suites de tests TrayTempo et affiche un résumé.
rem Sortie : exit code 0 si tout est vert, 1 sinon.
rem =====================================================================
setlocal enabledelayedexpansion
set PASS=0
set FAIL=0
for %%t in (test_timer test_stats test_config test_notifications test_tray_integration test_updater test_floating test_dialogs) do (
echo.
echo === %%t ===
nim c -r --hints:off tests\%%t.nim
if !errorlevel! equ 0 (
echo [PASS] %%t
set /a PASS+=1
) else (
echo [FAIL] %%t
set /a FAIL+=1
)
)
echo.
echo ==============================
echo Resultat : !PASS! reussi(s), !FAIL! echoue(s)
echo ==============================
if !FAIL! gtr 0 (
exit /b 1
)
exit /b 0
+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 = ""
+120
View File
@@ -0,0 +1,120 @@
## test_config.nim
## ================
## Tests d'intégration de config.nim : valeurs par défaut, résolution de profil,
## round-trip JSON (save/load), tolérance aux fichiers corrompus, et
## démarrage automatique via la clé de registre Run.
import std/[unittest, os]
import ../src/config
# Redirige APPDATA vers un répertoire temporaire pour isoler les fichiers.
let tmpApd = getTempDir() / "traytempo_test_apd"
if dirExists(tmpApd):
removeDir(tmpApd)
putEnv("APPDATA", tmpApd)
suite "config":
test "defaultConfig has expected values":
let c = defaultConfig()
check c.workMin == 25
check c.shortBreakMin == 5
check c.longBreakMin == 15
check c.cyclesBeforeLongBreak == 4
check c.soundEnabled == true
check c.doNotDisturb == false
check c.autoStart == false
check c.activeProfile == "Classique"
check c.floatingWindow == false
check c.profiles.len == 3
check c.healthReminders.len == 5
test "getActiveProfile returns the matching profile":
let c = defaultConfig()
let p = c.getActiveProfile()
check p.name == "Classique"
check p.workMin == 25
check p.cycles == 4
test "getActiveProfile falls back to defaults for an unknown name":
var c = defaultConfig()
c.activeProfile = "N'existe pas"
let p = c.getActiveProfile()
check p.name == "Défaut"
check p.workMin == c.workMin
test "saveConfig then loadConfig round-trips (including accents)":
var c = defaultConfig()
c.workMin = 42
c.doNotDisturb = true
c.activeProfile = "Sprint"
saveConfig(c)
let l = loadConfig()
check l.workMin == 42
check l.doNotDisturb == true
check l.activeProfile == "Sprint"
check l.profiles.len == 3
check l.healthReminders.len == 5
# UTF-8 (accents) doit survivre à l'aller-retour JSON.
check l.healthReminders[0].text == "Lève-toi et marche 2 minutes"
test "loadConfig returns defaults on a corrupt file":
ensureConfigDir()
writeFile(configPath(), "{ pas du json valide")
let l = loadConfig()
check l.workMin == 25
check l.profiles.len == 3
test "new fields (lang, dailyGoal, hotkey, sound) round-trip":
var c = defaultConfig()
c.lang = "en"
c.dailyGoal = 8
c.hotkeyModifiers = 0x0003'i32
c.hotkeyVk = 0x50'i32
c.soundName = "SystemExclamation"
c.floatingWindow = true
saveConfig(c)
let l = loadConfig()
check l.lang == "en"
check l.dailyGoal == 8
check l.hotkeyModifiers == 0x0003'i32
check l.hotkeyVk == 0x50'i32
check l.soundName == "SystemExclamation"
check l.floatingWindow == true
test "English defaults localize profiles and reminders":
let c = defaultConfig(langEn)
check c.activeProfile == "Classic"
check c.profiles[0].name == "Classic"
check c.healthReminders[0].text == "Stand up and walk for 2 minutes"
test "loadConfig keeps defaults for missing new fields":
# Un ancien fichier sans les nouveaux champs doit rester lisible.
ensureConfigDir()
writeFile(configPath(), "{\"workMin\": 30}")
let l = loadConfig()
check l.workMin == 30
check l.lang == "auto"
check l.dailyGoal == 0
test "configDir returns exe dir in portable mode":
let exeDir = getAppFilename().parentDir
let marker = exeDir / "portable"
writeFile(marker, "")
check configDir() == exeDir
removeFile(marker)
check configDir() == (getEnv("APPDATA") / "TrayTempo")
test "acquireSingleInstance detects a second lock":
check not acquireSingleInstance("TrayTempo_Test_Mutex_0.2.0")
check acquireSingleInstance("TrayTempo_Test_Mutex_0.2.0")
suite "autostart registry":
test "setAutostart toggles the Run key and cleans up":
let before = isAutostartEnabled()
check setAutostart(true)
check isAutostartEnabled()
check setAutostart(false)
check not isAutostartEnabled()
# Restaure l'état d'origine pour ne rien laisser traîner.
if before:
discard setAutostart(true)
+34
View File
@@ -0,0 +1,34 @@
## test_dialogs.nim
## =================
## Test d'intégration de la construction du panneau de réglages : vérifie que la
## fenêtre et ses contrôles sont réellement créés (sans lancer la boucle modale,
## qui bloquerait). Cela rend observable la création de l'interface, au-delà de
## la simple compilation.
import std/unittest
import winim/lean
import ../src/dialogs
import ../src/config
proc createParent(): HWND =
let hInstance = GetModuleHandleW(nil)
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](DefWindowProcW)
wc.hInstance = hInstance
wc.lpszClassName = L"TrayTempoDialogTestClass"
discard RegisterClass(&wc)
result = CreateWindowExW(0, L"TrayTempoDialogTestClass", L"", WS_OVERLAPPED,
0, 0, 0, 0, 0, 0, hInstance, nil)
suite "dialogs":
test "settings dialog window and controls are built":
let parent = createParent()
check parent != 0
let hwnd = buildSettingsDialog(parent, defaultConfig())
check hwnd != 0
# La fenêtre doit avoir au moins un contrôle enfant (champ, bouton, etc.).
check GetWindow(hwnd, GW_CHILD) != 0
discard DestroyWindow(hwnd)
discard DestroyWindow(parent)
+18
View File
@@ -0,0 +1,18 @@
## test_floating.nim
## ==================
## Test d'intégration de la fenêtre flottante : crée, met à jour puis détruit
## la vraie fenêtre Win32 et vérifie que l'état est cohérent. Ce test réduit le
## fossé entre « compile » et « observé » pour cette fonctionnalité.
import std/unittest
import ../src/floating
suite "floating window":
test "show, update and hide manage a real window":
check not floatingVisible()
showFloating()
check floatingVisible()
# La mise à jour du texte ne doit pas lever d'erreur.
updateFloating("Travail 24:59")
hideFloating()
check not floatingVisible()
+43
View File
@@ -0,0 +1,43 @@
## test_notifications.nim
## ======================
## Tests unitaires de la rotation des rappels santé (logique pure). Aucun
## appel Win32 : on ne teste que nextHealthReminder.
import std/unittest
import ../src/notifications
import ../src/config
suite "health reminder rotation":
test "does not repeat the same reminder twice in a row":
let cfg = defaultConfig()
let a = nextHealthReminder(cfg)
let b = nextHealthReminder(cfg)
check a.len > 0
check b.len > 0
check a != b
test "a single enabled reminder is always returned":
var cfg = defaultConfig()
for i in 0 ..< cfg.healthReminders.len:
cfg.healthReminders[i].enabled = (i == 0)
check nextHealthReminder(cfg) == "Lève-toi et marche 2 minutes"
check nextHealthReminder(cfg) == "Lève-toi et marche 2 minutes"
test "returns empty string when nothing is enabled":
var cfg = defaultConfig()
for i in 0 ..< cfg.healthReminders.len:
cfg.healthReminders[i].enabled = false
check nextHealthReminder(cfg) == ""
test "skips disabled reminders":
var cfg = defaultConfig()
for i in 0 ..< cfg.healthReminders.len:
cfg.healthReminders[i].enabled = (i == 2)
check nextHealthReminder(cfg) == cfg.healthReminders[2].text
test "custom reminder text is returned verbatim":
var cfg = defaultConfig()
for i in 0 ..< cfg.healthReminders.len:
cfg.healthReminders[i].enabled = false
cfg.healthReminders.add HealthReminder(text: "Médite 5 minutes", enabled: true)
check nextHealthReminder(cfg) == "Médite 5 minutes"
+106
View File
@@ -0,0 +1,106 @@
## test_stats.nim
## ==============
## Tests unitaires des statistiques (compteurs jour/semaine et streak).
## Aucune écriture disque : on manipule directement la table `daily`.
import std/unittest
import std/times
import std/tables
import std/os
import std/strutils
import ../src/stats
suite "stats":
test "initStats starts empty":
let st = initStats()
check st.completedToday() == 0
check st.completedThisWeek() == 0
check st.streak() == 0
test "completedToday counts today's pomodoros":
var st = initStats()
st.daily[todayStr()] = 5
check st.completedToday() == 5
test "streak counts consecutive days ending today":
var st = initStats()
let today = now()
for i in 0 ..< 3:
st.daily[(today - i.days).format("yyyy-MM-dd")] = 1
check st.streak() == 3
test "streak starts from yesterday when today has no entry":
var st = initStats()
let today = now()
st.daily[(today - 1.days).format("yyyy-MM-dd")] = 1
st.daily[(today - 2.days).format("yyyy-MM-dd")] = 1
check st.streak() == 2
test "streak stops at the first gap":
var st = initStats()
let today = now()
st.daily[today.format("yyyy-MM-dd")] = 1
st.daily[(today - 1.days).format("yyyy-MM-dd")] = 1
# jour -2 manquant (gap)
st.daily[(today - 3.days).format("yyyy-MM-dd")] = 1
check st.streak() == 2
test "completedThisWeek sums the last 7 days":
var st = initStats()
let today = now()
for i in 0 ..< 7:
st.daily[(today - i.days).format("yyyy-MM-dd")] = 1
check st.completedThisWeek() == 7
test "totalPomodoros sums the whole history":
var st = initStats()
st.daily["2026-01-01"] = 2
st.daily["2026-01-02"] = 3
st.daily["2026-01-03"] = 5
check st.totalPomodoros() == 10
test "goalReached is true only when the goal is exactly met":
var st = initStats()
st.daily[todayStr()] = 8
check st.goalReached(8)
check not st.goalReached(4)
check not st.goalReached(0) # objectif 0 = désactivé
test "lastNDays returns n entries including empty days":
var st = initStats()
let today = now()
st.daily[today.format("yyyy-MM-dd")] = 4
let rows = st.lastNDays(7)
check rows.len == 7
# Le dernier élément correspond à aujourd'hui.
check rows[^1][0] == today.format("yyyy-MM-dd")
check rows[^1][1] == 4
test "exportCsv writes date,count lines":
var st = initStats()
st.daily["2026-08-13"] = 3
st.daily["2026-08-14"] = 5
let path = getTempDir() / "traytempo_test_export.csv"
check st.exportCsv(path)
let content = readFile(path)
check content.contains("date,count")
check content.contains("2026-08-13,3")
check content.contains("2026-08-14,5")
removeFile(path)
suite "stats persistence":
test "saveStats then loadStats round-trips":
# Isole l'écriture disque dans un APPDATA temporaire.
let tmpApd = getTempDir() / "traytempo_test_stats"
if dirExists(tmpApd):
removeDir(tmpApd)
putEnv("APPDATA", tmpApd)
var st = initStats()
st.daily["2026-08-13"] = 3
st.daily["2026-08-14"] = 5
saveStats(st)
let loaded = loadStats()
check loaded.daily["2026-08-13"] == 3
check loaded.daily["2026-08-14"] == 5
+144
View File
@@ -0,0 +1,144 @@
## test_timer.nim
## ==============
## Tests unitaires de la machine à états du minuteur (module pur, sans Win32).
import std/unittest
import ../src/timer
import ../src/config
proc testProfile(): Profile =
Profile(name: "Test", workMin: 25, shortBreakMin: 5, longBreakMin: 15, cycles: 4)
suite "timer":
test "initial state is idle":
let t = initTimer()
check t.phase == phIdle
check t.remainingSecs == 0
check t.cycleCount == 0
test "startWork sets working phase and duration":
var t = initTimer()
t.startWork(testProfile())
check t.phase == phWorking
check t.remainingSecs == 25 * 60
test "tick decrements remaining by one second":
var t = initTimer()
t.startWork(testProfile())
let ev = t.tick(testProfile())
check ev == evNone
check t.remainingSecs == 25 * 60 - 1
test "tick does nothing when idle":
var t = initTimer()
check t.tick(testProfile()) == evNone
check t.phase == phIdle
test "work completion transitions to short break (1 cycle)":
var t = initTimer()
let p = Profile(name: "T", workMin: 1, shortBreakMin: 5, longBreakMin: 15, cycles: 4)
t.startWork(p)
var ev = evNone
for i in 0 ..< 60:
ev = t.tick(p)
check ev == evWorkCompleted
check t.phase == phShortBreak
check t.remainingSecs == 5 * 60
check t.cycleCount == 1
test "break completion transitions back to work":
var t = initTimer()
let p = Profile(name: "T", workMin: 25, shortBreakMin: 1, longBreakMin: 15, cycles: 4)
t.startBreak(p, longBreak = false)
var ev = evNone
for i in 0 ..< 60:
ev = t.tick(p)
check ev == evBreakCompleted
check t.phase == phWorking
check t.remainingSecs == 25 * 60
test "cyclesBeforeLongBreak triggers a long break":
var t = initTimer()
let p = Profile(name: "T", workMin: 1, shortBreakMin: 5, longBreakMin: 15, cycles: 4)
t.cycleCount = 3 # la 4e période de travail doit déclencher la pause longue
t.startWork(p)
var ev = evNone
for i in 0 ..< 60:
ev = t.tick(p)
check ev == evWorkCompleted
check t.phase == phLongBreak
check t.remainingSecs == 15 * 60
test "pause preserves the remaining time and resume restores phase":
var t = initTimer()
t.startWork(testProfile())
let before = t.remainingSecs
t.pause()
check t.phase == phPaused
check t.remainingSecs == before
t.resume()
check t.phase == phWorking
test "pause on idle does nothing":
var t = initTimer()
t.pause()
check t.phase == phIdle
test "stop returns to idle":
var t = initTimer()
t.startWork(testProfile())
t.stop()
check t.phase == phIdle
check t.remainingSecs == 0
test "stop resets cycleCount so long break timing stays correct":
var t = initTimer()
let p = Profile(name: "T", workMin: 1, shortBreakMin: 1, longBreakMin: 1, cycles: 4)
t.cycleCount = 3
t.stop()
check t.cycleCount == 0
# Après redémarrage, il faut 4 cycles (pas 1) avant la pause longue.
t.startWork(p)
check t.cycleCount == 0
test "isBreak is true only during breaks":
var t = initTimer()
check not t.isBreak()
t.startWork(testProfile())
check not t.isBreak()
t.startBreak(testProfile(), longBreak = false)
check t.isBreak()
t.startBreak(testProfile(), longBreak = true)
check t.isBreak()
test "skipBreak jumps from break to work":
var t = initTimer()
let p = testProfile()
t.startBreak(p, longBreak = false)
t.skipBreak(p)
check t.phase == phWorking
check t.remainingSecs == p.workMin * 60
test "skipBreak does nothing while working":
var t = initTimer()
t.startWork(testProfile())
t.skipBreak(testProfile())
check t.phase == phWorking
test "extendBreak adds minutes to a break":
var t = initTimer()
t.startBreak(testProfile(), longBreak = false)
let before = t.remainingSecs
t.extendBreak(5)
check t.remainingSecs == before + 5 * 60
test "extendBreak does nothing while working":
var t = initTimer()
t.startWork(testProfile())
t.extendBreak(5)
check t.phase == phWorking
test "formatTime formats minutes and seconds":
check formatTime(0) == "0:00"
check formatTime(65) == "1:05"
check formatTime(3661) == "1:01:01"
+55
View File
@@ -0,0 +1,55 @@
## test_tray_integration.nim
## ==========================
## Test d'intégration Win32 : crée une vraie fenêtre cachée, puis exerce les
## interfaces publiques de `tray.nim` et `notifications.nim` contre les API
## réelles (user32/shell32) en vérifiant leurs codes de retour.
##
## Note : ce test fait brièvement apparaître une icône tray et une notification
## sur le bureau, puis nettoie tout à la fin.
import std/unittest
import winim/lean
import ../src/tray
import ../src/notifications
import ../src/timer
proc createTestWindow(): HWND =
let hInstance = GetModuleHandleW(nil)
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](DefWindowProcW)
wc.hInstance = hInstance
wc.lpszClassName = L"TrayTempoTestClass"
discard RegisterClass(&wc)
result = CreateWindowExW(0, L"TrayTempoTestClass", L"", WS_OVERLAPPED,
0, 0, 0, 0, 0, 0, hInstance, nil)
suite "tray integration":
test "tray icon, dynamic icon, toast and hotkey succeed":
let hwnd = createTestWindow()
check hwnd != 0
# Génération des icônes dynamiques (GDI) pour les 5 états.
for phase in [phIdle, phWorking, phShortBreak, phLongBreak, phPaused]:
check phaseIcon(phase) != 0
# Ajout de l'icône dans le tray (Shell_NotifyIcon NIM_ADD -> bool).
check addTrayIcon(hwnd, phaseIcon(phIdle), "TrayTempo") == true
# Mise à jour de l'icône et du tooltip.
updateTrayIcon(hwnd, phaseIcon(phWorking), "Travail: 25:00")
# Notification (NIF_INFO).
showToast(hwnd, "Test", "Notification d'intégration")
# Raccourci global (RegisterHotKey / UnregisterHotKey). Clé de test peu
# susceptible d'être déjà réservée (Ctrl+Alt+F10).
check RegisterHotKey(hwnd, 1'i32, MOD_CONTROL or MOD_ALT, 0x79) != 0
check UnregisterHotKey(hwnd, 1'i32) != 0
# Son de fin de période (alias système valide -> PlaySound retourne true).
check playSound("SystemAsterisk") == true
# Suppression de l'icône et de la fenêtre.
removeTrayIcon(hwnd)
discard DestroyWindow(hwnd)
+29
View File
@@ -0,0 +1,29 @@
## test_updater.nim
## ==================
## Tests unitaires des fonctions pures du module updater (comparaison de
## versions). Aucun accès réseau.
import std/unittest
import ../src/updater
suite "updater":
test "parseVersion splits dotted numbers":
check parseVersion("0.2.0") == @[0, 2, 0]
check parseVersion("1.10.3") == @[1, 10, 3]
check parseVersion("2") == @[2]
test "parseVersion tolerates non-numeric components":
check parseVersion("v1.2") == @[0, 2]
test "isNewer detects a newer version":
check isNewer("0.2.0", "0.3.0")
check isNewer("0.2.9", "0.2.10")
check isNewer("1.0.0", "1.0.1")
test "isNewer returns false for equal or older versions":
check not isNewer("0.3.0", "0.3.0")
check not isNewer("0.3.0", "0.2.9")
check not isNewer("1.0.0", "0.9.9")
test "fetchLatestVersion returns empty for empty URL":
check fetchLatestVersion("") == ""
+12
View File
@@ -0,0 +1,12 @@
# Package
version = "0.2.0"
author = "TrayTempo"
description = "Pomodoro timer with health reminders living in the Windows system tray"
license = "MIT"
srcDir = "src"
bin = @["traytempo"]
# Dependencies
requires "nim >= 2.0.0"
requires "winim >= 3.14.0"