commit 3218972ddf77663e9527ff381647c48f180be078 Author: Bruno Charest Date: Fri Aug 14 10:39:21 2026 -0400 TrayTempo v0.2.0 : minuteur Pomodoro Windows en Nim avec interface intuitive diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6982b99 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..662b8b9 --- /dev/null +++ b/README.md @@ -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 diff --git a/VERIFICATION.md b/VERIFICATION.md new file mode 100644 index 0000000..f27651b --- /dev/null +++ b/VERIFICATION.md @@ -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). diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..763e8fd --- /dev/null +++ b/config.example.json @@ -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 } + ] +} diff --git a/installer/traytempo.iss b/installer/traytempo.iss new file mode 100644 index 0000000..643d1bf --- /dev/null +++ b/installer/traytempo.iss @@ -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 diff --git a/release.cmd b/release.cmd new file mode 100644 index 0000000..fc450b0 --- /dev/null +++ b/release.cmd @@ -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 diff --git a/run_tests.cmd b/run_tests.cmd new file mode 100644 index 0000000..543bf42 --- /dev/null +++ b/run_tests.cmd @@ -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 diff --git a/src/config.nim b/src/config.nim new file mode 100644 index 0000000..ed11dbb --- /dev/null +++ b/src/config.nim @@ -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 diff --git a/src/dialogs.nim b/src/dialogs.nim new file mode 100644 index 0000000..6224cb8 --- /dev/null +++ b/src/dialogs.nim @@ -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 diff --git a/src/floating.nim b/src/floating.nim new file mode 100644 index 0000000..6286634 --- /dev/null +++ b/src/floating.nim @@ -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 diff --git a/src/i18n.nim b/src/i18n.nim new file mode 100644 index 0000000..fcab07b --- /dev/null +++ b/src/i18n.nim @@ -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 diff --git a/src/notifications.nim b/src/notifications.nim new file mode 100644 index 0000000..45a0543 --- /dev/null +++ b/src/notifications.nim @@ -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 diff --git a/src/stats.nim b/src/stats.nim new file mode 100644 index 0000000..8ba0225 --- /dev/null +++ b/src/stats.nim @@ -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 diff --git a/src/timer.nim b/src/timer.nim new file mode 100644 index 0000000..fbc5c72 --- /dev/null +++ b/src/timer.nim @@ -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 diff --git a/src/tray.nim b/src/tray.nim new file mode 100644 index 0000000..9ef60ea --- /dev/null +++ b/src/tray.nim @@ -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) diff --git a/src/traytempo.nim b/src/traytempo.nim new file mode 100644 index 0000000..e01205c --- /dev/null +++ b/src/traytempo.nim @@ -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() diff --git a/src/updater.nim b/src/updater.nim new file mode 100644 index 0000000..aebfba7 --- /dev/null +++ b/src/updater.nim @@ -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 = "" diff --git a/tests/test_config.nim b/tests/test_config.nim new file mode 100644 index 0000000..b0d497a --- /dev/null +++ b/tests/test_config.nim @@ -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) diff --git a/tests/test_dialogs.nim b/tests/test_dialogs.nim new file mode 100644 index 0000000..360c55e --- /dev/null +++ b/tests/test_dialogs.nim @@ -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) diff --git a/tests/test_floating.nim b/tests/test_floating.nim new file mode 100644 index 0000000..8674c03 --- /dev/null +++ b/tests/test_floating.nim @@ -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() diff --git a/tests/test_notifications.nim b/tests/test_notifications.nim new file mode 100644 index 0000000..ccf3fae --- /dev/null +++ b/tests/test_notifications.nim @@ -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" diff --git a/tests/test_stats.nim b/tests/test_stats.nim new file mode 100644 index 0000000..c58e712 --- /dev/null +++ b/tests/test_stats.nim @@ -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 diff --git a/tests/test_timer.nim b/tests/test_timer.nim new file mode 100644 index 0000000..3339eb5 --- /dev/null +++ b/tests/test_timer.nim @@ -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" diff --git a/tests/test_tray_integration.nim b/tests/test_tray_integration.nim new file mode 100644 index 0000000..6012680 --- /dev/null +++ b/tests/test_tray_integration.nim @@ -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) diff --git a/tests/test_updater.nim b/tests/test_updater.nim new file mode 100644 index 0000000..47a1d26 --- /dev/null +++ b/tests/test_updater.nim @@ -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("") == "" diff --git a/traytempo.nimble b/traytempo.nimble new file mode 100644 index 0000000..c3b1820 --- /dev/null +++ b/traytempo.nimble @@ -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"