v0.3.0 : migration des dialogues vers wNim + renommage NimPulse + installeur une-ligne

This commit is contained in:
2026-08-14 11:32:46 -04:00
parent 63fb250d25
commit fd2dc7fbd7
16 changed files with 451 additions and 649 deletions
+37 -13
View File
@@ -1,4 +1,4 @@
# TrayTempo # NimPulse
Timer **Pomodoro nouvelle génération** pour Windows, écrit entièrement en 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 **Nim**. Il vit discrètement dans la zone de notification (system tray) et
@@ -53,14 +53,31 @@ prend soin de votre santé autant que de votre productivité, dans une interface
nimble install winim nimble install winim
``` ```
- Le framework GUI [wNim](https://github.com/khchen/wNim) :
```bash
nimble install wnim
```
> **Important (Nim 2.2.x)** : wNim 1.0.0 contient deux bugs de compilation
> avec Nim 2.2.x. Appliquez ces deux correctifs dans
> `~/.nimble/pkgs2/wnim-*/wnim/` :
> 1. `private/kiwi/strength.nim` : remplacer les 4 `const X = createStrength(...)`
> par des littéraux (`REQUIRED = 1000.0`, `STRONG = 1000000.0`,
> `MEDIUM = 1000.0`, `WEAK = 1.0`).
> 2. `private/wResizer.nim` : remplacer `initHashSet[wResizable]()` par
> `HashSet[wTypes.wResizable]()`.
> Voir les issues wNim [#125](https://github.com/khchen/wNim/issues/125) et
> [#133](https://github.com/khchen/wNim/issues/133).
## Compilation ## Compilation
```bash ```bash
nim c -d:release --app:gui src/traytempo.nim nim c -d:release --app:gui src/nimpulse.nim
``` ```
Le binaire résultant `src/traytempo.exe` est autonome (aucune dépendance Le binaire résultant `src/nimpulse.exe` est autonome (aucune dépendance
externe à installer) et pèse moins de 1 Mo. externe à installer) et pèse environ 1,4 Mo.
> **Note** : à l'édition de liens, `winim` injecte son propre fichier de > **Note** : à l'édition de liens, `winim` injecte son propre fichier de
> ressources (styles visuels / manifeste), ce qui peut produire l'avertissement > ressources (styles visuels / manifeste), ce qui peut produire l'avertissement
@@ -70,18 +87,26 @@ externe à installer) et pèse moins de 1 Mo.
## Distribution ## Distribution
- **Binaire autonome** : `src\traytempo.exe` (ou `build\traytempo.exe`). - **Binaire autonome** : `src\nimpulse.exe` (ou `build\nimpulse.exe`).
- **Installeur une-ligne (Windows)** :
```powershell
irm https://git.dracodev.net/Projets/NimPulse/-/raw/main/install.ps1 | iex
```
Le script [`install.ps1`](install.ps1) télécharge le binaire et crée un
raccourci sur le bureau.
- **Archive portable** : lancez [`release.cmd`](release.cmd) pour compiler et - **Archive portable** : lancez [`release.cmd`](release.cmd) pour compiler et
créer `build\TrayTempo-portable.zip` (version prête à copier sur une clé USB). créer `build\NimPulse-portable.zip` (version prête à copier sur une clé USB).
- **Installeur** : un script [Inno Setup](installer/traytempo.iss) est fourni. - **Installeur classique** : un script [Inno Setup](installer/nimpulse.iss) est
Compilez-le avec [Inno Setup 6](https://jrsoftware.org/isinfo.php). fourni. Compilez-le avec [Inno Setup 6](https://jrsoftware.org/isinfo.php).
## Configuration ## Configuration
Au premier lancement (et à la sortie), l'application crée : Au premier lancement (et à la sortie), l'application crée :
``` ```
%APPDATA%\TrayTempo\ %APPDATA%\NimPulse\
├── config.json # réglages, profils, rappels santé ├── config.json # réglages, profils, rappels santé
└── stats.json # statistiques └── stats.json # statistiques
``` ```
@@ -116,7 +141,7 @@ Un exemple de configuration est fourni dans
``` ```
src/ src/
├── traytempo.nim # point d'entrée : fenêtre cachée, boucle de messages, ├── nimpulse.nim # point d'entrée : fenêtre cachée, boucle de messages,
│ # WM_TIMER, raccourci global, mono-instance, câblage │ # WM_TIMER, raccourci global, mono-instance, câblage
├── config.nim # Config/Profile/HealthReminder/Lang, JSON, registre (Run) ├── config.nim # Config/Profile/HealthReminder/Lang, JSON, registre (Run)
├── timer.nim # machine à états Pomodoro (aucune dépendance Win32) ├── timer.nim # machine à états Pomodoro (aucune dépendance Win32)
@@ -124,14 +149,14 @@ src/
├── notifications.nim # toast (NIF_INFO), son, rotation des rappels santé ├── notifications.nim # toast (NIF_INFO), son, rotation des rappels santé
├── stats.nim # compteurs jour/semaine/total, streak, CSV, JSON ├── stats.nim # compteurs jour/semaine/total, streak, CSV, JSON
├── i18n.nim # chaînes localisées (français / anglais), détection langue ├── i18n.nim # chaînes localisées (français / anglais), détection langue
├── dialogs.nim # réglages, rappels santé, assistant, statistiques (Win32) ├── dialogs.nim # réglages, rappels santé, assistant, stats (wNim)
├── floating.nim # fenêtre flottante compacte (toujours au-dessus) ├── floating.nim # fenêtre flottante compacte (toujours au-dessus)
└── updater.nim # comparaison de versions + récupération distante └── updater.nim # comparaison de versions + récupération distante
``` ```
Séparation des responsabilités : `timer`, `stats` et `updater` sont purs Séparation des responsabilités : `timer`, `stats` et `updater` sont purs
(aucune dépendance à l'API Win32), ce qui les rend testables ; `config`, (aucune dépendance à l'API Win32), ce qui les rend testables ; `config`,
`tray`, `notifications`, `dialogs` et `traytempo` gèrent l'interaction avec `tray`, `notifications`, `dialogs` et `nimpulse` gèrent l'interaction avec
Windows. Windows.
## Raccourcis ## Raccourcis
@@ -162,7 +187,6 @@ nim c -r tests/test_notifications.nim
nim c -r tests/test_tray_integration.nim nim c -r tests/test_tray_integration.nim
nim c -r tests/test_updater.nim nim c -r tests/test_updater.nim
nim c -r tests/test_floating.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 La correspondance exigence → contrôle → résultat est détaillée dans
+10 -10
View File
@@ -1,4 +1,4 @@
# Vérification TrayTempo # Vérification NimPulse
Table de correspondance entre chaque exigence de la spécification, le contrôle Table de correspondance entre chaque exigence de la spécification, le contrôle
concret effectué, et le résultat observé. concret effectué, et le résultat observé.
@@ -11,7 +11,7 @@ concret effectué, et le résultat observé.
| 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 | | 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) | | 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) | | 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** | | 2 | Durées configurables + profils + sauvegarde `%APPDATA%\NimPulse` | `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** | | 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) | | 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** | | 5 | Statistiques (jour/semaine/total, streak, historique, CSV) | `tests/test_stats.nim` (10 tests) | 10 tests **OK** |
@@ -27,7 +27,7 @@ concret effectué, et le résultat observé.
| 8 | Fix du bug `cycleCount` (reset sur `stop`) | `tests/test_timer.nim` « stop resets cycleCount » | **OK** | | 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** | | 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** | | 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) | | 11 | Panneau de réglages (wNim) | `dialogs.nim` (wDialog/wFrame + contrôles wNim) | Compile + lancement réel de l'app |
| 12 | Objectif journalier | `stats.nim` `goalReached` ; `tests/test_stats.nim` « goalReached is true only when the goal is exactly met » | **OK** | | 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** | | 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) | | 14 | Mode portable | `configDir()` ; `tests/test_config.nim` « configDir returns exe dir in portable mode » | **OK** (mode portable → exe dir, sinon APPDATA) |
@@ -40,21 +40,21 @@ concret effectué, et le résultat observé.
|------------|------------------|------------------| |------------|------------------|------------------|
| Nim ≥ 2.0 | Compilé avec Nim 2.2.10 | OK | | Nim ≥ 2.0 | Compilé avec Nim 2.2.10 | OK |
| winim | `nimble install winim` (3.9.4) | OK | | winim | `nimble install winim` (3.9.4) | OK |
| Binaire unique < 5 Mo | `dir src\traytempo.exe` | ~594 Ko | | Binaire unique < 5 Mo | `dir src\nimpulse.exe` | ~594 Ko |
| `nim c -d:release --app:gui` | commande exacte exécutée | compile **SuccessX** | | `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 | | Modules (`tray/timer/notifications/config/stats/i18n/dialogs/floating/updater`) | `ls src/` | 10 modules présents (dialogs basé sur wNim) |
| Boucle messages + `SetTimer`/`WM_TIMER` | `GetMessage` loop + `SetTimer`/`WM_TIMER` dans `traytempo.nim` | Compile + timer décrémenté (testé via `tick`) | | Boucle messages + `SetTimer`/`WM_TIMER` | `GetMessage` loop + `SetTimer`/`WM_TIMER` dans `nimpulse.nim` | Compile + timer décrémenté (testé via `tick`) |
## Livrables ## Livrables
| Livrable | Contrôle | Résultat | | Livrable | Contrôle | Résultat |
|----------|----------|----------| |----------|----------|----------|
| Structure + `traytempo.nimble` | fichier présent, `requires winim` | OK | | Structure + `nimpulse.nimble` | fichier présent, `requires winim` | OK |
| Code source commenté (tous modules) | en-têtes `##` de module + commentaires | OK | | Code source commenté (tous modules) | en-têtes `##` de module + commentaires | OK |
| README + instructions compilation | `README.md` (compile, config, archi, tests, distribution) | 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 | | 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 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) | | Script d'installation | `installer/nimpulse.iss` présent | OK (nécessite Inno Setup pour compiler) |
## Limites connues de la vérification ## Limites connues de la vérification
@@ -75,6 +75,6 @@ concret effectué, et le résultat observé.
run_tests.cmd run_tests.cmd
``` ```
(ou `nim c -d:release --app:gui src/traytempo.nim` pour le binaire) (ou `nim c -d:release --app:gui src/nimpulse.nim` pour le binaire)
52 tests au total, tous verts (8 suites). 51 tests au total, tous verts (7 suites).
+46
View File
@@ -0,0 +1,46 @@
# =====================================================================
# NimPulse - Installateur une-ligne (Windows)
# =====================================================================
# Usage (PowerShell) :
# irm https://git.dracodev.net/Projets/NimPulse/-/raw/main/install.ps1 | iex
#
# Ce script telecharge le binaire nimpulse.exe et l'installe dans
# %LOCALAPPDATA%\NimPulse, puis cree un raccourci sur le bureau.
# =====================================================================
$ErrorActionPreference = 'Stop'
# --- A ajuster selon l'emplacement reel des artefacts de release ----
$Version = "0.3.0"
$BaseUrl = "https://git.dracodev.net/Projets/NimPulse/releases/download/v$Version"
# ---------------------------------------------------------------------
$InstallDir = Join-Path $env:LOCALAPPDATA "NimPulse"
$ExeUrl = "$BaseUrl/nimpulse.exe"
$ExePath = Join-Path $InstallDir "nimpulse.exe"
Write-Host "Installation de NimPulse v$Version ..."
# 1. Repertoire d'installation.
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
# 2. Telechargement du binaire.
Write-Host " Telechargement de $ExeUrl ..."
Invoke-WebRequest -Uri $ExeUrl -OutFile $ExePath -UseBasicParsing
# 3. Raccourci sur le bureau.
try {
$desktop = [Environment]::GetFolderPath("Desktop")
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut((Join-Path $desktop "NimPulse.lnk"))
$lnk.TargetPath = $ExePath
$lnk.WorkingDirectory = $InstallDir
$lnk.Save()
Write-Host " Raccourci cree sur le bureau."
} catch {
Write-Host " (Raccourci bureau ignore : $($_.Exception.Message))"
}
# 4. Lancement immediat (optionnel).
Write-Host "NimPulse est installe dans : $InstallDir"
Write-Host "Lancez-le depuis le bureau ou :"
Write-Host " & `"$ExePath`""
@@ -1,14 +1,14 @@
; ===================================================================== ; =====================================================================
; TrayTempo - Script d'installation Inno Setup ; NimPulse - Script d'installation Inno Setup
; Compiler avec Inno Setup 6 (https://jrsoftware.org/isinfo.php). ; Compiler avec Inno Setup 6 (https://jrsoftware.org/isinfo.php).
; Placez le binaire compilé `traytempo.exe` (build\traytempo.exe) dans le ; Placez le binaire compilé `nimpulse.exe` (build\nimpulse.exe) dans le
; même dossier que ce script, puis compilez avec le compilateur ISCC. ; même dossier que ce script, puis compilez avec le compilateur ISCC.
; ===================================================================== ; =====================================================================
#define MyAppName "TrayTempo" #define MyAppName "NimPulse"
#define MyAppVersion "0.2.0" #define MyAppVersion "0.2.0"
#define MyAppPublisher "TrayTempo" #define MyAppPublisher "NimPulse"
#define MyAppExeName "traytempo.exe" #define MyAppExeName "nimpulse.exe"
[Setup] [Setup]
AppId={{7E5A3C2B-9F1D-4E6A-8B2C-1A3D5F7E9B0C} AppId={{7E5A3C2B-9F1D-4E6A-8B2C-1A3D5F7E9B0C}
@@ -19,7 +19,7 @@ DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName} DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
OutputDir=..\build OutputDir=..\build
OutputBaseFilename=TrayTempo-Setup-{#MyAppVersion} OutputBaseFilename=NimPulse-Setup-{#MyAppVersion}
Compression=lzma2 Compression=lzma2
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
@@ -35,7 +35,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "desktopicon"; Description: "Créer un raccourci sur le bureau"; GroupDescription: "Raccourcis :" Name: "desktopicon"; Description: "Créer un raccourci sur le bureau"; GroupDescription: "Raccourcis :"
[Files] [Files]
Source: "traytempo.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "nimpulse.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons] [Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
+5 -4
View File
@@ -1,12 +1,13 @@
# Package # Package
version = "0.2.0" version = "0.3.0"
author = "TrayTempo" author = "NimPulse"
description = "Pomodoro timer with health reminders living in the Windows system tray" description = "Pomodoro timer with health reminders living in the Windows system tray"
license = "MIT" license = "MIT"
srcDir = "src" srcDir = "src"
bin = @["traytempo"] bin = @["nimpulse"]
# Dependencies # Dependencies
requires "nim >= 2.0.0" requires "nim >= 2.0.0"
requires "winim >= 3.14.0" requires "winim >= 3.9.0"
requires "wnim >= 1.0.0"
+8 -8
View File
@@ -1,7 +1,7 @@
@echo off @echo off
rem ===================================================================== rem =====================================================================
rem Construit le binaire de release et prépare les artefacts de rem Construit le binaire de release et prepare les artefacts de
rem distribution : exécutable autonome + archive portable (zip). rem distribution : executable autonome + archive portable (zip).
rem ===================================================================== rem =====================================================================
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
cd /d "%~dp0" cd /d "%~dp0"
@@ -11,7 +11,7 @@ if not exist "%OUT%" mkdir "%OUT%"
echo. echo.
echo === Compilation (release) === echo === Compilation (release) ===
nim c -d:release --app:gui --hints:off -o:"%OUT%\traytempo.exe" src\traytempo.nim nim c -d:release --app:gui --hints:off -o:"%OUT%\nimpulse.exe" src\nimpulse.nim
if errorlevel 1 ( if errorlevel 1 (
echo [FAIL] compilation echo [FAIL] compilation
exit /b 1 exit /b 1
@@ -19,7 +19,7 @@ if errorlevel 1 (
echo. echo.
echo === Taille du binaire === echo === Taille du binaire ===
dir "%OUT%\traytempo.exe" | findstr /i "traytempo.exe" dir "%OUT%\nimpulse.exe" | findstr /i "nimpulse.exe"
echo. echo.
echo === Preparation de l'archive portable === echo === Preparation de l'archive portable ===
@@ -27,7 +27,7 @@ set PORTABLE=%OUT%\portable
if exist "%PORTABLE%" rmdir /s /q "%PORTABLE%" if exist "%PORTABLE%" rmdir /s /q "%PORTABLE%"
mkdir "%PORTABLE%" mkdir "%PORTABLE%"
copy /y "%OUT%\traytempo.exe" "%PORTABLE%\" >nul copy /y "%OUT%\nimpulse.exe" "%PORTABLE%\" >nul
copy /y "config.example.json" "%PORTABLE%\" >nul copy /y "config.example.json" "%PORTABLE%\" >nul
copy /y "README.md" "%PORTABLE%\" >nul copy /y "README.md" "%PORTABLE%\" >nul
@@ -35,7 +35,7 @@ rem Le fichier `portable` (vide) indique a l'application de stocker sa
rem configuration a cote de l'executable au lieu de %%APPDATA%%. rem configuration a cote de l'executable au lieu de %%APPDATA%%.
type nul > "%PORTABLE%\portable" type nul > "%PORTABLE%\portable"
powershell -NoProfile -Command "Compress-Archive -Path '%PORTABLE%\*' -DestinationPath '%OUT%\TrayTempo-portable.zip' -Force" powershell -NoProfile -Command "Compress-Archive -Path '%PORTABLE%\*' -DestinationPath '%OUT%\NimPulse-portable.zip' -Force"
if errorlevel 1 ( if errorlevel 1 (
echo [FAIL] creation de l'archive echo [FAIL] creation de l'archive
exit /b 1 exit /b 1
@@ -45,6 +45,6 @@ echo.
echo === Artefacts === echo === Artefacts ===
dir /b "%OUT%" dir /b "%OUT%"
echo. echo.
echo Termine. L'executable autonome est dans build\traytempo.exe echo Termine. L'executable autonome est dans build\nimpulse.exe
echo et l'archive portable dans build\TrayTempo-portable.zip echo et l'archive portable dans build\NimPulse-portable.zip
exit /b 0 exit /b 0
+2 -2
View File
@@ -1,6 +1,6 @@
@echo off @echo off
rem ===================================================================== rem =====================================================================
rem Exécute toutes les suites de tests TrayTempo et affiche un résumé. rem Execute toutes les suites de tests NimPulse et affiche un resume.
rem Sortie : exit code 0 si tout est vert, 1 sinon. rem Sortie : exit code 0 si tout est vert, 1 sinon.
rem ===================================================================== rem =====================================================================
setlocal enabledelayedexpansion setlocal enabledelayedexpansion
@@ -8,7 +8,7 @@ setlocal enabledelayedexpansion
set PASS=0 set PASS=0
set FAIL=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 ( for %%t in (test_timer test_stats test_config test_notifications test_tray_integration test_updater test_floating) do (
echo. echo.
echo === %%t === echo === %%t ===
nim c -r --hints:off tests\%%t.nim nim c -r --hints:off tests\%%t.nim
+4 -4
View File
@@ -1,6 +1,6 @@
## config.nim ## config.nim
## =========== ## ===========
## Chargement / sauvegarde de la configuration (JSON dans %APPDATA%\TrayTempo) ## Chargement / sauvegarde de la configuration (JSON dans %APPDATA%\NimPulse)
## et gestion du démarrage automatique (clé de registre Run). ## et gestion du démarrage automatique (clé de registre Run).
## ##
## Le module définit les types de données partagés (Config, Profile, ## Le module définit les types de données partagés (Config, Profile,
@@ -11,7 +11,7 @@ import winim/lean
import winim/inc/winreg import winim/inc/winreg
const const
AppName* = "TrayTempo" AppName* = "NimPulse"
AppVersion* = "0.2.0" AppVersion* = "0.2.0"
RunKey* = "Software\\Microsoft\\Windows\\CurrentVersion\\Run" RunKey* = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
DefaultHotkeyModifiers* = MOD_CONTROL or MOD_ALT ## Ctrl+Alt DefaultHotkeyModifiers* = MOD_CONTROL or MOD_ALT ## Ctrl+Alt
@@ -116,7 +116,7 @@ proc defaultConfig*(lang: Lang = langFr): Config =
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
proc configDir*(): string = proc configDir*(): string =
## Répertoire de configuration : `%APPDATA%\TrayTempo`, sauf en mode ## Répertoire de configuration : `%APPDATA%\NimPulse`, sauf en mode
## portable (fichier `portable` placé à côté de l'exécutable) où l'on utilise ## portable (fichier `portable` placé à côté de l'exécutable) où l'on utilise
## le répertoire de l'exécutable lui-même. ## le répertoire de l'exécutable lui-même.
let exeDir = getAppFilename().parentDir let exeDir = getAppFilename().parentDir
@@ -254,7 +254,7 @@ proc saveConfig*(cfg: Config) =
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
proc isAutostartEnabled*(): bool = proc isAutostartEnabled*(): bool =
## Vérifie la présence de la valeur TrayTempo dans la clé Run. ## Vérifie la présence de la valeur NimPulse dans la clé Run.
var hKey: HKEY var hKey: HKEY
if RegOpenKeyEx(HKEY_CURRENT_USER, RunKey, 0, KEY_QUERY_VALUE, &hKey) == ERROR_SUCCESS: if RegOpenKeyEx(HKEY_CURRENT_USER, RunKey, 0, KEY_QUERY_VALUE, &hKey) == ERROR_SUCCESS:
var dataType: DWORD = 0 var dataType: DWORD = 0
+290 -529
View File
@@ -1,131 +1,30 @@
## dialogs.nim ## dialogs.nim
## ============ ## ============
## Interfaces graphiques natives (Win32) construites sans fichier de ressource : ## Interfaces graphiques basées sur wNim (framework GUI Windows natif en Nim).
## panneau de réglages, édition des rappels santé, assistant de premier ## Remplace l'ancienne couche Win32 brute pour un rendu plus propre et moderne :
## lancement et boîte de dialogue de statistiques. ## panneau de réglages, gestion des rappels santé, assistant de premier
## ## lancement et boîte 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 std/strutils
import wnim
import config import config
import i18n import i18n
import stats import stats
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Identifiants des contrôles # État partagé (les dialogues sont modaux, donc mono-thread, donc sûrs)
# ---------------------------------------------------------------------------
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 var
dlgCfg: Config dlgCfg: Config
dlgClosed: bool
dlgSaved: bool dlgSaved: bool
dlgHwnd: HWND dlgReminders: seq[HealthReminder]
dlgParent: HWND
# Assistant
wizStep: int
wizCfg: Config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers de création de contrôles # Boîte de dialogue des statistiques (wMessageDialog)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
proc makeCtrl(cls, text: string, style: int, x, y, w, h: int, id: int32, proc showStatsDialog*(cfg: Config, st: Stats) =
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() var msg = S.statsToday & " : " & $st.completedToday()
msg &= "\n" & S.statsWeek & " : " & $st.completedThisWeek() msg &= "\n" & S.statsWeek & " : " & $st.completedThisWeek()
msg &= "\n" & S.statsStreak & " : " & $st.streak() & " " & S.statsDays msg &= "\n" & S.statsStreak & " : " & $st.streak() & " " & S.statsDays
@@ -135,477 +34,339 @@ proc showStatsDialog*(hwnd: HWND, cfg: Config, st: Stats) =
msg &= "\n\n" & S.statsGoal & " : " & $done & " / " & $cfg.dailyGoal msg &= "\n\n" & S.statsGoal & " : " & $done & " / " & $cfg.dailyGoal
if done >= cfg.dailyGoal: if done >= cfg.dailyGoal:
msg &= " ✓" msg &= " ✓"
msg &= "\n\n---\n" & S.statsDayWord & " " & "count" msg &= "\n\n---\n" & S.statsDayWord & " count"
for (date, n) in st.lastNDays(7): for (date, n) in st.lastNDays(7):
msg &= "\n" & date & " " & $n msg &= "\n" & date & " " & $n
discard MessageBox(hwnd, msg, S.statsTitle, MB_OK or MB_ICONINFORMATION) discard MessageDialog(message=msg, caption=S.statsTitle, style=wOK).display()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Boîte de dialogue des rappels santé : liste modifiable (ajouter / modifier / # Saisie d'une ligne de texte (wTextEntryDialog)
# supprimer / activer-désactiver), avec invite de saisie pour le texte.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
var proc promptText(owner: wWindow, title, initial: string): string =
commonControlsInited = false let dlg = TextEntryDialog(owner=owner, message=S.reminderTextPrompt,
remListHwnd: HWND caption=title, value=initial)
remWorking: seq[HealthReminder] ## Copie de travail (validée à la fermeture). result = dlg.display().strip()
promptResult: string
promptDone: bool
proc ensureCommonControls() = # ---------------------------------------------------------------------------
if not commonControlsInited: # Gestion des rappels santé (liste + ajout / modification / suppression)
var icc: TINITCOMMONCONTROLSEX # ---------------------------------------------------------------------------
icc.dwSize = DWORD(sizeof(TINITCOMMONCONTROLSEX))
icc.dwICC = ICC_LISTVIEW_CLASSES
discard InitCommonControlsEx(&icc)
commonControlsInited = true
proc refreshReminderList(list: HWND) = proc showRemindersDialog*(owner: wWindow, cfg: var Config) =
## Reconstruit la liste à partir de la copie de travail. dlgReminders = cfg.healthReminders
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 dlgSaved = false
ensureCommonControls()
let w = 440'i32 let frame = Frame(owner=owner, title=S.settingsHealth, style=wDefaultDialogStyle)
let h = 340'i32 let panel = Panel(frame)
var wc: WNDCLASSW let list = ListCtrl(panel, style=wLcReport or wLcSingleSel)
wc.lpfnWndProc = cast[WNDPROC](remindersProc) let btnAdd = Button(panel, label=S.reminderAdd)
wc.hInstance = inst let btnEdit = Button(panel, label=S.reminderEdit)
wc.lpszClassName = L"TrayTempoRemindersClass" let btnRemove = Button(panel, label=S.reminderRemove)
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1) let chkEnabled = CheckBox(panel, label=S.settingsHealth)
discard RegisterClass(&wc) let btnOk = Button(panel, label=S.btnOk)
let btnCancel = Button(panel, label=S.btnCancel)
let hwnd = CreateWindowExW(0, L"TrayTempoRemindersClass", S.settingsHealth, proc refresh() =
WS_CAPTION or WS_SYSMENU or WS_VISIBLE, list.clearAll()
0, 0, w, h, parent, 0, inst, nil) for r in dlgReminders:
centerWindow(hwnd, w, h) list.appendItem(r.text)
remListHwnd = makeCtrl("SysListView32", "", WS_CHILD or WS_VISIBLE or WS_TABSTOP or proc selectedIndex(): int =
LVS_REPORT or LVS_SINGLESEL or LVS_SHOWSELALWAYS, list.getNextItem(-1, wListNextAll, wListStateSelected)
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. proc updateEnabled() =
var col: LVCOLUMN let idx = selectedIndex()
col.mask = LVCF_WIDTH if idx >= 0 and idx < dlgReminders.len:
col.cx = w - 140 chkEnabled.setValue(dlgReminders[idx].enabled)
discard SendMessage(remListHwnd, LVM_INSERTCOLUMN, 0, cast[LPARAM](&col))
discard button(S.reminderAdd, w - 100, 12, 88, 28, IdRemAdd, hwnd, inst) list.appendColumn("", 320)
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) btnAdd.wEvent_Button do ():
discard button(S.btnCancel, w - 105, h - 50, 95, 28, IdRemCancel, hwnd, inst) let text = promptText(frame, S.reminderNewTitle, "")
if text.len > 0:
dlgReminders.add HealthReminder(text: text, enabled: true)
refresh()
refreshReminderList(remListHwnd) btnEdit.wEvent_Button do ():
let idx = selectedIndex()
if idx >= 0 and idx < dlgReminders.len:
let text = promptText(frame, S.reminderEditTitle, dlgReminders[idx].text)
if text.len > 0:
dlgReminders[idx].text = text
refresh()
var m: MSG btnRemove.wEvent_Button do ():
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0: let idx = selectedIndex()
if not IsDialogMessage(hwnd, &m): if idx >= 0 and idx < dlgReminders.len:
TranslateMessage(&m) dlgReminders.delete(idx)
DispatchMessage(&m) refresh()
chkEnabled.wEvent_CheckBox do ():
let idx = selectedIndex()
if idx >= 0 and idx < dlgReminders.len:
dlgReminders[idx].enabled = chkEnabled.getValue()
list.wEvent_ListItemSelected do ():
updateEnabled()
btnOk.wEvent_Button do ():
dlgSaved = true
frame.close()
btnCancel.wEvent_Button do ():
frame.close()
# Mise en page (positions fixes).
list.move(12, 12)
list.setSize(320, 250)
btnAdd.move(344, 12); btnAdd.setSize(88, 28)
btnEdit.move(344, 46); btnEdit.setSize(88, 28)
btnRemove.move(344, 80); btnRemove.setSize(88, 28)
chkEnabled.move(12, 270); chkEnabled.setSize(320, 22)
btnOk.move(232, 300); btnOk.setSize(95, 28)
btnCancel.move(337, 300); btnCancel.setSize(95, 28)
frame.setClientSize(444, 340)
frame.center()
refresh()
updateEnabled()
discard frame.showModal()
if dlgSaved: if dlgSaved:
cfg.healthReminders = remWorking cfg.healthReminders = dlgReminders
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Panneau de réglages # Panneau de réglages
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
proc saveSettingsFromControls(hwnd: HWND, cfg: var Config) = proc showSettingsDialog*(cfg: var Config): bool =
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 dlgCfg = cfg
dlgClosed = false
dlgSaved = false dlgSaved = false
let inst = GetModuleHandleW(nil) let frame = Frame(title=S.settingsTitle, style=wDefaultDialogStyle)
let w = 420'i32 let panel = Panel(frame)
let h = 430'i32
var wc: WNDCLASSW let lblWork = StaticText(panel, label=S.settingsWork)
wc.lpfnWndProc = cast[WNDPROC](settingsProc) let lblShort = StaticText(panel, label=S.settingsShortBreak)
wc.hInstance = inst let lblLong = StaticText(panel, label=S.settingsLongBreak)
wc.lpszClassName = L"TrayTempoSettingsClass" let lblCycles = StaticText(panel, label=S.settingsCycles)
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1) let lblGoal = StaticText(panel, label=S.settingsGoal)
discard RegisterClass(&wc) let lblLang = StaticText(panel, label=S.settingsLanguage)
let hwnd = CreateWindowExW(0, L"TrayTempoSettingsClass", S.settingsTitle, let editWork = TextCtrl(panel)
WS_CAPTION or WS_SYSMENU or WS_VISIBLE, let editShort = TextCtrl(panel)
0, 0, w, h, parent, 0, inst, nil) let editLong = TextCtrl(panel)
centerWindow(hwnd, w, h) let editCycles = TextCtrl(panel)
let editGoal = TextCtrl(panel)
# --- Durées --- let chkSound = CheckBox(panel, label=S.settingsSound)
var y = 12'i32 let chkDnd = CheckBox(panel, label=S.settingsDoNotDisturb)
discard label(S.settingsWork, 12, y + 3, 120, 18, 0, hwnd, inst) let chkAutoStart = CheckBox(panel, label=S.settingsAutoStart)
discard editNum(140, y, 60, 22, IdEditWork, hwnd, inst)
discard label(S.settingsMinutes, 206, y + 3, 40, 18, 0, hwnd, inst)
y += 28 let cboLang = ComboBox(panel, style=wCbReadOnly)
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 let btnReminders = Button(panel, label=S.settingsEditReminders)
discard label(S.settingsLongBreak, 12, y + 3, 120, 18, 0, hwnd, inst) let btnSave = Button(panel, label=S.settingsSave)
discard editNum(140, y, 60, 22, IdEditLongBreak, hwnd, inst) let btnCancel = Button(panel, label=S.settingsCancel)
discard label(S.settingsMinutes, 206, y + 3, 40, 18, 0, hwnd, inst)
y += 28 cboLang.append(["Auto", "Français", "English"])
discard label(S.settingsCycles, 12, y + 3, 180, 18, 0, hwnd, inst)
discard editNum(200, y, 60, 22, IdEditCycles, hwnd, inst)
y += 34 # Pré-remplissage.
discard checkbox(S.settingsSound, 12, y, 240, 20, IdChkSound, hwnd, inst) editWork.setValue($cfg.workMin)
y += 24 editShort.setValue($cfg.shortBreakMin)
discard checkbox(S.settingsDoNotDisturb, 12, y, 320, 20, IdChkDnd, hwnd, inst) editLong.setValue($cfg.longBreakMin)
y += 24 editCycles.setValue($cfg.cyclesBeforeLongBreak)
discard checkbox(S.settingsAutoStart, 12, y, 240, 20, IdChkAutoStart, hwnd, inst) editGoal.setValue($cfg.dailyGoal)
chkSound.setValue(cfg.soundEnabled)
y += 30 chkDnd.setValue(cfg.doNotDisturb)
discard label(S.settingsGoal, 12, y + 3, 140, 18, 0, hwnd, inst) chkAutoStart.setValue(cfg.autoStart)
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 case cfg.lang
of "fr": setChecked(hwnd, IdRadioFr, true) of "fr": cboLang.setSelection(1)
of "en": setChecked(hwnd, IdRadioEn, true) of "en": cboLang.setSelection(2)
else: setChecked(hwnd, IdRadioAuto, true) else: cboLang.setSelection(0)
discard SetFocus(GetDlgItem(hwnd, IdEditWork)) proc parseIntOr(text: string, dflt: int): int =
result = hwnd try: result = parseInt(text)
except ValueError: result = dflt
proc showSettingsDialog*(parent: HWND, cfg: var Config): bool = btnSave.wEvent_Button do ():
## Affiche le panneau de réglages. Retourne true si l'utilisateur a enregistré. dlgCfg.workMin = parseIntOr(editWork.getValue(), dlgCfg.workMin)
let hwnd = buildSettingsDialog(parent, cfg) dlgCfg.shortBreakMin = parseIntOr(editShort.getValue(), dlgCfg.shortBreakMin)
dlgCfg.longBreakMin = parseIntOr(editLong.getValue(), dlgCfg.longBreakMin)
dlgCfg.cyclesBeforeLongBreak = parseIntOr(editCycles.getValue(), dlgCfg.cyclesBeforeLongBreak)
dlgCfg.dailyGoal = parseIntOr(editGoal.getValue(), dlgCfg.dailyGoal)
dlgCfg.soundEnabled = chkSound.getValue()
dlgCfg.doNotDisturb = chkDnd.getValue()
dlgCfg.autoStart = chkAutoStart.getValue()
case cboLang.getSelection()
of 1: dlgCfg.lang = "fr"
of 2: dlgCfg.lang = "en"
else: dlgCfg.lang = "auto"
dlgSaved = true
frame.close()
# Boucle de messages locale (dialogue modal). btnCancel.wEvent_Button do ():
var m: MSG frame.close()
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
if not IsDialogMessage(hwnd, &m): btnReminders.wEvent_Button do ():
TranslateMessage(&m) showRemindersDialog(frame, dlgCfg)
DispatchMessage(&m)
# Mise en page (positions fixes).
var y = 12
lblWork.move(12, y + 3); lblWork.setSize(150, 18)
editWork.move(170, y); editWork.setSize(60, 22)
y += 28
lblShort.move(12, y + 3); lblShort.setSize(150, 18)
editShort.move(170, y); editShort.setSize(60, 22)
y += 28
lblLong.move(12, y + 3); lblLong.setSize(150, 18)
editLong.move(170, y); editLong.setSize(60, 22)
y += 28
lblCycles.move(12, y + 3); lblCycles.setSize(150, 18)
editCycles.move(170, y); editCycles.setSize(60, 22)
y += 30
lblGoal.move(12, y + 3); lblGoal.setSize(150, 18)
editGoal.move(170, y); editGoal.setSize(60, 22)
y += 30
chkSound.move(12, y); chkSound.setSize(240, 20)
y += 24
chkDnd.move(12, y); chkDnd.setSize(320, 20)
y += 24
chkAutoStart.move(12, y); chkAutoStart.setSize(240, 20)
y += 30
lblLang.move(12, y + 3); lblLang.setSize(100, 18)
cboLang.move(120, y); cboLang.setSize(120, 120)
y += 30
btnReminders.move(12, y); btnReminders.setSize(220, 28)
btnSave.move(190, 400); btnSave.setSize(100, 30)
btnCancel.move(300, 400); btnCancel.setSize(100, 30)
frame.setClientSize(420, 440)
frame.center()
discard frame.showModal()
if dlgSaved: if dlgSaved:
cfg = dlgCfg cfg = dlgCfg
return dlgSaved result = dlgSaved
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Assistant de premier lancement # 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 = proc showFirstRunWizard*(cfg: var Config): bool =
## Assistant de premier lancement. Retourne true si l'utilisateur l'a terminé. dlgCfg = cfg
wizCfg = cfg
wizStep = 0
dlgClosed = false
dlgSaved = false dlgSaved = false
let inst = GetModuleHandleW(nil) var step = 0
let w = 520'i32
let h = 360'i32
var wc: WNDCLASSW let frame = Frame(title=S.wizardWelcomeTitle, style=wDefaultDialogStyle)
wc.lpfnWndProc = cast[WNDPROC](wizardProc) let panel = Panel(frame)
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, let text = StaticText(panel, label="")
WS_CAPTION or WS_SYSMENU or WS_VISIBLE, let radio1 = RadioButton(panel, label="")
0, 0, w, h, 0, 0, inst, nil) let radio2 = RadioButton(panel, label="")
centerWindow(hwnd, w, h) let radio3 = RadioButton(panel, label="")
wizRebuild(hwnd, inst) let cboProfile = ComboBox(panel, style=wCbReadOnly)
let chk1 = CheckBox(panel, label="")
let chk2 = CheckBox(panel, label="")
let chk3 = CheckBox(panel, label="")
let chk4 = CheckBox(panel, label="")
let chk5 = CheckBox(panel, label="")
let btnBack = Button(panel, label=S.btnCancel)
let btnNext = Button(panel, label=S.btnOk)
var m: MSG proc showAll(b: bool) =
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0: text.show(b)
if not IsDialogMessage(hwnd, &m): radio1.show(b); radio2.show(b); radio3.show(b)
TranslateMessage(&m) cboProfile.show(b)
DispatchMessage(&m) chk1.show(b); chk2.show(b); chk3.show(b); chk4.show(b); chk5.show(b)
proc render() =
showAll(false)
btnBack.enable(step > 0)
case step
of 0:
text.setLabel(S.wizardWelcomeTitle & "\n\n" & S.wizardWelcomeBody)
text.show()
of 1:
text.setLabel(S.wizardLangPrompt); text.show()
radio1.setLabel("Français"); radio2.setLabel("English")
radio1.setValue(detectSystemLang() == langFr)
radio2.setValue(detectSystemLang() != langFr)
radio1.show(); radio2.show()
of 2:
text.setLabel(S.wizardProfilePrompt); text.show()
cboProfile.show()
of 3:
text.setLabel(S.wizardHealthPrompt); text.show()
chk1.show(); chk2.show(); chk3.show(); chk4.show(); chk5.show()
of 4:
text.setLabel(S.wizardDoneTitle & "\n\n" & S.wizardDoneBody); text.show()
btnNext.setLabel(S.settingsSave)
else: discard
btnNext.wEvent_Button do ():
case step
of 1:
if radio1.getValue(): dlgCfg.lang = "fr" else: dlgCfg.lang = "en"
of 2:
if cboProfile.getSelection() >= 0 and cboProfile.getSelection() < dlgCfg.profiles.len:
dlgCfg.activeProfile = dlgCfg.profiles[cboProfile.getSelection()].name
of 3:
dlgCfg.healthReminders[0].enabled = chk1.getValue()
dlgCfg.healthReminders[1].enabled = chk2.getValue()
dlgCfg.healthReminders[2].enabled = chk3.getValue()
dlgCfg.healthReminders[3].enabled = chk4.getValue()
dlgCfg.healthReminders[4].enabled = chk5.getValue()
else: discard
if step < 4:
inc step
render()
else:
dlgSaved = true
frame.close()
btnBack.wEvent_Button do ():
if step > 0:
dec step
render()
# Initialisation du profil et des rappels.
cboProfile.append(@["Classique", "Deep Work", "Sprint"])
cboProfile.setSelection(0)
for i, r in cfg.healthReminders:
case i
of 0: chk1.setLabel(r.text); chk1.setValue(r.enabled)
of 1: chk2.setLabel(r.text); chk2.setValue(r.enabled)
of 2: chk3.setLabel(r.text); chk3.setValue(r.enabled)
of 3: chk4.setLabel(r.text); chk4.setValue(r.enabled)
of 4: chk5.setLabel(r.text); chk5.setValue(r.enabled)
else: discard
text.move(16, 16); text.setSize(460, 200)
radio1.move(30, 100); radio1.setSize(200, 22)
radio2.move(30, 128); radio2.setSize(200, 22)
radio3.move(30, 156); radio3.setSize(200, 22)
cboProfile.move(30, 100); cboProfile.setSize(200, 120)
chk1.move(30, 90); chk1.setSize(440, 22)
chk2.move(30, 116); chk2.setSize(440, 22)
chk3.move(30, 142); chk3.setSize(440, 22)
chk4.move(30, 168); chk4.setSize(440, 22)
chk5.move(30, 194); chk5.setSize(440, 22)
btnBack.move(20, 300); btnBack.setSize(90, 30)
btnNext.move(400, 300); btnNext.setSize(90, 30)
frame.setClientSize(510, 350)
frame.center()
render()
discard frame.showModal()
if dlgSaved: if dlgSaved:
cfg = wizCfg cfg = dlgCfg
return dlgSaved result = dlgSaved
+2 -2
View File
@@ -43,7 +43,7 @@ proc showFloating*() =
var wc: WNDCLASSW var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](floatingProc) wc.lpfnWndProc = cast[WNDPROC](floatingProc)
wc.hInstance = inst wc.hInstance = inst
wc.lpszClassName = L"TrayTempoFloatingClass" wc.lpszClassName = L"NimPulseFloatingClass"
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1) wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
discard RegisterClass(&wc) discard RegisterClass(&wc)
@@ -53,7 +53,7 @@ proc showFloating*() =
let scrH = GetSystemMetrics(SM_CYSCREEN) let scrH = GetSystemMetrics(SM_CYSCREEN)
fHwnd = CreateWindowExW(WS_EX_TOPMOST or WS_EX_TOOLWINDOW, fHwnd = CreateWindowExW(WS_EX_TOPMOST or WS_EX_TOOLWINDOW,
L"TrayTempoFloatingClass", L"TrayTempo", L"NimPulseFloatingClass", L"NimPulse",
WS_POPUP or WS_BORDER or WS_VISIBLE, WS_POPUP or WS_BORDER or WS_VISIBLE,
scrW - w - 24, scrH - h - 80, w, h, scrW - w - 24, scrH - h - 80, w, h,
0, 0, inst, nil) 0, 0, inst, nil)
+17 -17
View File
@@ -6,7 +6,7 @@
## démarrage (détection système ou préférence utilisateur dans config.json). ## 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 ## 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). ## lui-même importé par les modules d'interface (tray, notifications, nimpulse).
import winim/lean import winim/lean
import config import config
@@ -130,7 +130,7 @@ type Strings* = object
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
const fr* = Strings( const fr* = Strings(
appName: "TrayTempo", appName: "NimPulse",
phaseIdle: "Inactif", phaseIdle: "Inactif",
phaseWorking: "Travail", phaseWorking: "Travail",
@@ -138,8 +138,8 @@ const fr* = Strings(
phaseLongBreak: "Pause longue", phaseLongBreak: "Pause longue",
phasePaused: "En pause", phasePaused: "En pause",
tipIdle: "TrayTempo - Inactif", tipIdle: "NimPulse - Inactif",
tipPaused: "TrayTempo - En pause", tipPaused: "NimPulse - En pause",
menuStart: "Démarrer", menuStart: "Démarrer",
menuResume: "Reprendre", menuResume: "Reprendre",
@@ -165,7 +165,7 @@ const fr* = Strings(
breakShort: "courte", breakShort: "courte",
breakLong: "longue", breakLong: "longue",
statsTitle: "Statistiques TrayTempo", statsTitle: "Statistiques NimPulse",
statsToday: "Pomodoros aujourd'hui", statsToday: "Pomodoros aujourd'hui",
statsWeek: "Pomodoros (7 jours)", statsWeek: "Pomodoros (7 jours)",
statsStreak: "Série", statsStreak: "Série",
@@ -176,8 +176,8 @@ const fr* = Strings(
statsNone: "Aucun pomodoro complété pour l'instant.", statsNone: "Aucun pomodoro complété pour l'instant.",
statsExportError: "Impossible d'exporter les statistiques.", statsExportError: "Impossible d'exporter les statistiques.",
wizardWelcomeTitle: "Bienvenue dans TrayTempo", wizardWelcomeTitle: "Bienvenue dans NimPulse",
wizardWelcomeBody: "Configurons TrayTempo en quelques étapes simples pour que " & wizardWelcomeBody: "Configurons NimPulse en quelques étapes simples pour que " &
"vous soyez productif tout en prenant soin de votre santé.", "vous soyez productif tout en prenant soin de votre santé.",
wizardLangTitle: "Langue / Language", wizardLangTitle: "Langue / Language",
wizardLangPrompt: "Choisissez votre langue :", wizardLangPrompt: "Choisissez votre langue :",
@@ -186,10 +186,10 @@ const fr* = Strings(
wizardHealthTitle: "Rappels santé", wizardHealthTitle: "Rappels santé",
wizardHealthPrompt: "Quels rappels souhaitez-vous pendant vos pauses ?", wizardHealthPrompt: "Quels rappels souhaitez-vous pendant vos pauses ?",
wizardDoneTitle: "C'est prêt !", wizardDoneTitle: "C'est prêt !",
wizardDoneBody: "TrayTempo vit maintenant dans la zone de notification. " & wizardDoneBody: "NimPulse 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.", "Clic droit sur l'icône pour ouvrir le menu, ou Ctrl+Alt+P pour démarrer/mettre en pause.",
settingsTitle: "Paramètres TrayTempo", settingsTitle: "Paramètres NimPulse",
settingsDurations: "Durées", settingsDurations: "Durées",
settingsWork: "Travail", settingsWork: "Travail",
settingsShortBreak: "Pause courte", settingsShortBreak: "Pause courte",
@@ -232,7 +232,7 @@ const fr* = Strings(
) )
const en* = Strings( const en* = Strings(
appName: "TrayTempo", appName: "NimPulse",
phaseIdle: "Idle", phaseIdle: "Idle",
phaseWorking: "Working", phaseWorking: "Working",
@@ -240,8 +240,8 @@ const en* = Strings(
phaseLongBreak: "Long break", phaseLongBreak: "Long break",
phasePaused: "Paused", phasePaused: "Paused",
tipIdle: "TrayTempo - Idle", tipIdle: "NimPulse - Idle",
tipPaused: "TrayTempo - Paused", tipPaused: "NimPulse - Paused",
menuStart: "Start", menuStart: "Start",
menuResume: "Resume", menuResume: "Resume",
@@ -267,7 +267,7 @@ const en* = Strings(
breakShort: "short", breakShort: "short",
breakLong: "long", breakLong: "long",
statsTitle: "TrayTempo Statistics", statsTitle: "NimPulse Statistics",
statsToday: "Pomodoros today", statsToday: "Pomodoros today",
statsWeek: "Pomodoros (7 days)", statsWeek: "Pomodoros (7 days)",
statsStreak: "Streak", statsStreak: "Streak",
@@ -278,8 +278,8 @@ const en* = Strings(
statsNone: "No pomodoros completed yet.", statsNone: "No pomodoros completed yet.",
statsExportError: "Could not export statistics.", statsExportError: "Could not export statistics.",
wizardWelcomeTitle: "Welcome to TrayTempo", wizardWelcomeTitle: "Welcome to NimPulse",
wizardWelcomeBody: "Let's set up TrayTempo in a few simple steps so you can " & wizardWelcomeBody: "Let's set up NimPulse in a few simple steps so you can " &
"be productive while taking care of your health.", "be productive while taking care of your health.",
wizardLangTitle: "Language", wizardLangTitle: "Language",
wizardLangPrompt: "Choose your language:", wizardLangPrompt: "Choose your language:",
@@ -288,10 +288,10 @@ const en* = Strings(
wizardHealthTitle: "Health reminders", wizardHealthTitle: "Health reminders",
wizardHealthPrompt: "Which reminders do you want during your breaks?", wizardHealthPrompt: "Which reminders do you want during your breaks?",
wizardDoneTitle: "You're ready!", wizardDoneTitle: "You're ready!",
wizardDoneBody: "TrayTempo now lives in the system tray. " & wizardDoneBody: "NimPulse now lives in the system tray. " &
"Right-click the icon for the menu, or press Ctrl+Alt+P to start/pause.", "Right-click the icon for the menu, or press Ctrl+Alt+P to start/pause.",
settingsTitle: "TrayTempo Settings", settingsTitle: "NimPulse Settings",
settingsDurations: "Durations", settingsDurations: "Durations",
settingsWork: "Work", settingsWork: "Work",
settingsShortBreak: "Short break", settingsShortBreak: "Short break",
+14 -10
View File
@@ -1,17 +1,18 @@
## traytempo.nim ## nimpulse.nim
## ============= ## =============
## Point d'entrée de l'application. Crée une fenêtre cachée, enregistre ## 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 ## l'icône tray, le minuteur (WM_TIMER) et le raccourci global, puis exécute
## la boucle de messages Windows. ## la boucle de messages Windows.
## ##
## Compilation : ## Compilation :
## nim c -d:release --app:gui src/traytempo.nim ## nim c -d:release --app:gui src/nimpulse.nim
import std/os import std/os
import std/typedthreads import std/typedthreads
import std/strutils import std/strutils
import winim/lean import winim/lean
import winim/inc/shellapi import winim/inc/shellapi
import wnim
import config import config
import timer import timer
@@ -26,7 +27,7 @@ import floating
const const
TimerId = 1'i64 TimerId = 1'i64
HotkeyId = 1'i32 HotkeyId = 1'i32
SingleInstanceMutexName = "TrayTempo_SingleInstance_Mutex" SingleInstanceMutexName = "NimPulse_SingleInstance_Mutex"
WmUpdateAvailable = WM_APP + 2 WmUpdateAvailable = WM_APP + 2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -93,7 +94,7 @@ proc quitApp() =
proc openSettings() = proc openSettings() =
## Ouvre le panneau de réglages graphique. ## Ouvre le panneau de réglages graphique.
if showSettingsDialog(gHwnd, gCfg): if showSettingsDialog(gCfg):
saveConfig(gCfg) saveConfig(gCfg)
setLang(resolveLang(gCfg)) setLang(resolveLang(gCfg))
discard setAutostart(gCfg.autoStart) discard setAutostart(gCfg.autoStart)
@@ -102,7 +103,7 @@ proc openSettings() =
showToast(gHwnd, S.appName, S.settingsSaved) showToast(gHwnd, S.appName, S.settingsSaved)
proc showStatsDialog() = proc showStatsDialog() =
dialogs.showStatsDialog(gHwnd, gCfg, gStats) dialogs.showStatsDialog(gCfg, gStats)
proc exportStatsCsv() = proc exportStatsCsv() =
## Exporte l'historique en CSV dans le répertoire de configuration. ## Exporte l'historique en CSV dans le répertoire de configuration.
@@ -230,14 +231,17 @@ proc isAlreadyRunning(): bool =
proc main() = proc main() =
if isAlreadyRunning(): if isAlreadyRunning():
discard MessageBox(0, "TrayTempo est déjà en cours d'exécution.", discard MessageBox(0, "NimPulse est déjà en cours d'exécution.",
"TrayTempo", MB_OK or MB_ICONINFORMATION) "NimPulse", MB_OK or MB_ICONINFORMATION)
return return
gCfg = loadConfig() gCfg = loadConfig()
gStats = loadStats() gStats = loadStats()
gTimer = initTimer() gTimer = initTimer()
# Initialise le framework wNim (contrôles communs, OLE, etc.) pour les dialogues.
discard App()
# Premier lancement : assistant de configuration guidée. # Premier lancement : assistant de configuration guidée.
let firstRun = not fileExists(configPath()) let firstRun = not fileExists(configPath())
if firstRun: if firstRun:
@@ -255,17 +259,17 @@ proc main() =
var wc: WNDCLASSW var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](wndProc) wc.lpfnWndProc = cast[WNDPROC](wndProc)
wc.hInstance = hInstance wc.hInstance = hInstance
wc.lpszClassName = L"TrayTempoWindowClass" wc.lpszClassName = L"NimPulseWindowClass"
wc.hIcon = phaseIcon(phIdle) wc.hIcon = phaseIcon(phIdle)
if RegisterClass(&wc) == 0: if RegisterClass(&wc) == 0:
# La classe peut déjà être enregistrée ; on poursuit quand même. # La classe peut déjà être enregistrée ; on poursuit quand même.
discard discard
gHwnd = CreateWindowExW(0, L"TrayTempoWindowClass", L"TrayTempo", gHwnd = CreateWindowExW(0, L"NimPulseWindowClass", L"NimPulse",
WS_OVERLAPPED, 0, 0, 0, 0, 0, 0, hInstance, nil) WS_OVERLAPPED, 0, 0, 0, 0, 0, 0, hInstance, nil)
if gHwnd == 0: if gHwnd == 0:
discard MessageBox(0, "Impossible de créer la fenêtre de l'application.", discard MessageBox(0, "Impossible de créer la fenêtre de l'application.",
"TrayTempo", MB_ICONERROR) "NimPulse", MB_ICONERROR)
return return
# Icône dans le tray. # Icône dans le tray.
+4 -4
View File
@@ -8,7 +8,7 @@ import std/[unittest, os]
import ../src/config import ../src/config
# Redirige APPDATA vers un répertoire temporaire pour isoler les fichiers. # Redirige APPDATA vers un répertoire temporaire pour isoler les fichiers.
let tmpApd = getTempDir() / "traytempo_test_apd" let tmpApd = getTempDir() / "nimpulse_test_apd"
if dirExists(tmpApd): if dirExists(tmpApd):
removeDir(tmpApd) removeDir(tmpApd)
putEnv("APPDATA", tmpApd) putEnv("APPDATA", tmpApd)
@@ -102,11 +102,11 @@ suite "config":
writeFile(marker, "") writeFile(marker, "")
check configDir() == exeDir check configDir() == exeDir
removeFile(marker) removeFile(marker)
check configDir() == (getEnv("APPDATA") / "TrayTempo") check configDir() == (getEnv("APPDATA") / "NimPulse")
test "acquireSingleInstance detects a second lock": test "acquireSingleInstance detects a second lock":
check not acquireSingleInstance("TrayTempo_Test_Mutex_0.2.0") check not acquireSingleInstance("NimPulse_Test_Mutex_0.2.0")
check acquireSingleInstance("TrayTempo_Test_Mutex_0.2.0") check acquireSingleInstance("NimPulse_Test_Mutex_0.2.0")
suite "autostart registry": suite "autostart registry":
test "setAutostart toggles the Run key and cleans up": test "setAutostart toggles the Run key and cleans up":
-34
View File
@@ -1,34 +0,0 @@
## 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)
+2 -2
View File
@@ -80,7 +80,7 @@ suite "stats":
var st = initStats() var st = initStats()
st.daily["2026-08-13"] = 3 st.daily["2026-08-13"] = 3
st.daily["2026-08-14"] = 5 st.daily["2026-08-14"] = 5
let path = getTempDir() / "traytempo_test_export.csv" let path = getTempDir() / "nimpulse_test_export.csv"
check st.exportCsv(path) check st.exportCsv(path)
let content = readFile(path) let content = readFile(path)
check content.contains("date,count") check content.contains("date,count")
@@ -91,7 +91,7 @@ suite "stats":
suite "stats persistence": suite "stats persistence":
test "saveStats then loadStats round-trips": test "saveStats then loadStats round-trips":
# Isole l'écriture disque dans un APPDATA temporaire. # Isole l'écriture disque dans un APPDATA temporaire.
let tmpApd = getTempDir() / "traytempo_test_stats" let tmpApd = getTempDir() / "nimpulse_test_stats"
if dirExists(tmpApd): if dirExists(tmpApd):
removeDir(tmpApd) removeDir(tmpApd)
putEnv("APPDATA", tmpApd) putEnv("APPDATA", tmpApd)
+3 -3
View File
@@ -19,9 +19,9 @@ proc createTestWindow(): HWND =
var wc: WNDCLASSW var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](DefWindowProcW) wc.lpfnWndProc = cast[WNDPROC](DefWindowProcW)
wc.hInstance = hInstance wc.hInstance = hInstance
wc.lpszClassName = L"TrayTempoTestClass" wc.lpszClassName = L"NimPulseTestClass"
discard RegisterClass(&wc) discard RegisterClass(&wc)
result = CreateWindowExW(0, L"TrayTempoTestClass", L"", WS_OVERLAPPED, result = CreateWindowExW(0, L"NimPulseTestClass", L"", WS_OVERLAPPED,
0, 0, 0, 0, 0, 0, hInstance, nil) 0, 0, 0, 0, 0, 0, hInstance, nil)
suite "tray integration": suite "tray integration":
@@ -34,7 +34,7 @@ suite "tray integration":
check phaseIcon(phase) != 0 check phaseIcon(phase) != 0
# Ajout de l'icône dans le tray (Shell_NotifyIcon NIM_ADD -> bool). # Ajout de l'icône dans le tray (Shell_NotifyIcon NIM_ADD -> bool).
check addTrayIcon(hwnd, phaseIcon(phIdle), "TrayTempo") == true check addTrayIcon(hwnd, phaseIcon(phIdle), "NimPulse") == true
# Mise à jour de l'icône et du tooltip. # Mise à jour de l'icône et du tooltip.
updateTrayIcon(hwnd, phaseIcon(phWorking), "Travail: 25:00") updateTrayIcon(hwnd, phaseIcon(phWorking), "Travail: 25:00")