TrayTempo v0.2.0 : minuteur Pomodoro Windows en Nim avec interface intuitive
This commit is contained in:
+12
@@ -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
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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 = ""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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("") == ""
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user