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
**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
```
- 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
```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
externe à installer) et pèse moins de 1 Mo.
Le binaire résultant `src/nimpulse.exe` est autonome (aucune dépendance
externe à installer) et pèse environ 1,4 Mo.
> **Note** : à l'édition de liens, `winim` injecte son propre fichier de
> ressources (styles visuels / manifeste), ce qui peut produire l'avertissement
@@ -70,18 +87,26 @@ externe à installer) et pèse moins de 1 Mo.
## 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
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).
créer `build\NimPulse-portable.zip` (version prête à copier sur une clé USB).
- **Installeur classique** : un script [Inno Setup](installer/nimpulse.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\
%APPDATA%\NimPulse\
├── config.json # réglages, profils, rappels santé
└── stats.json # statistiques
```
@@ -116,7 +141,7 @@ Un exemple de configuration est fourni dans
```
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
├── config.nim # Config/Profile/HealthReminder/Lang, JSON, registre (Run)
├── timer.nim # machine à états Pomodoro (aucune dépendance Win32)
@@ -124,14 +149,14 @@ src/
├── 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)
├── dialogs.nim # réglages, rappels santé, assistant, stats (wNim)
├── 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
`tray`, `notifications`, `dialogs` et `nimpulse` gèrent l'interaction avec
Windows.
## 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_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
+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
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 |
| 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** |
| 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** |
| 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** |
@@ -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** |
| 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) |
| 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** |
| 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) |
@@ -40,21 +40,21 @@ concret effectué, et le 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 |
| Binaire unique < 5 Mo | `dir src\nimpulse.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`) |
| 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 `nimpulse.nim` | Compile + timer décrémenté (testé via `tick`) |
## Livrables
| 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 |
| 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) |
| Script d'installation | `installer/nimpulse.iss` présent | OK (nécessite Inno Setup pour compiler) |
## Limites connues de la vérification
@@ -75,6 +75,6 @@ concret effectué, et le résultat observé.
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).
; 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.
; =====================================================================
#define MyAppName "TrayTempo"
#define MyAppName "NimPulse"
#define MyAppVersion "0.2.0"
#define MyAppPublisher "TrayTempo"
#define MyAppExeName "traytempo.exe"
#define MyAppPublisher "NimPulse"
#define MyAppExeName "nimpulse.exe"
[Setup]
AppId={{7E5A3C2B-9F1D-4E6A-8B2C-1A3D5F7E9B0C}
@@ -19,7 +19,7 @@ DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
OutputDir=..\build
OutputBaseFilename=TrayTempo-Setup-{#MyAppVersion}
OutputBaseFilename=NimPulse-Setup-{#MyAppVersion}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
@@ -35,7 +35,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "desktopicon"; Description: "Créer un raccourci sur le bureau"; GroupDescription: "Raccourcis :"
[Files]
Source: "traytempo.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "nimpulse.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
+5 -4
View File
@@ -1,12 +1,13 @@
# Package
version = "0.2.0"
author = "TrayTempo"
version = "0.3.0"
author = "NimPulse"
description = "Pomodoro timer with health reminders living in the Windows system tray"
license = "MIT"
srcDir = "src"
bin = @["traytempo"]
bin = @["nimpulse"]
# Dependencies
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
rem =====================================================================
rem Construit le binaire de release et prépare les artefacts de
rem distribution : exécutable autonome + archive portable (zip).
rem Construit le binaire de release et prepare les artefacts de
rem distribution : executable autonome + archive portable (zip).
rem =====================================================================
setlocal enabledelayedexpansion
cd /d "%~dp0"
@@ -11,7 +11,7 @@ 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
nim c -d:release --app:gui --hints:off -o:"%OUT%\nimpulse.exe" src\nimpulse.nim
if errorlevel 1 (
echo [FAIL] compilation
exit /b 1
@@ -19,7 +19,7 @@ if errorlevel 1 (
echo.
echo === Taille du binaire ===
dir "%OUT%\traytempo.exe" | findstr /i "traytempo.exe"
dir "%OUT%\nimpulse.exe" | findstr /i "nimpulse.exe"
echo.
echo === Preparation de l'archive portable ===
@@ -27,7 +27,7 @@ set PORTABLE=%OUT%\portable
if exist "%PORTABLE%" rmdir /s /q "%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 "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%%.
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 (
echo [FAIL] creation de l'archive
exit /b 1
@@ -45,6 +45,6 @@ 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
echo Termine. L'executable autonome est dans build\nimpulse.exe
echo et l'archive portable dans build\NimPulse-portable.zip
exit /b 0
+2 -2
View File
@@ -1,6 +1,6 @@
@echo off
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 =====================================================================
setlocal enabledelayedexpansion
@@ -8,7 +8,7 @@ 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 (
for %%t in (test_timer test_stats test_config test_notifications test_tray_integration test_updater test_floating) do (
echo.
echo === %%t ===
nim c -r --hints:off tests\%%t.nim
+4 -4
View File
@@ -1,6 +1,6 @@
## 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).
##
## Le module définit les types de données partagés (Config, Profile,
@@ -11,7 +11,7 @@ import winim/lean
import winim/inc/winreg
const
AppName* = "TrayTempo"
AppName* = "NimPulse"
AppVersion* = "0.2.0"
RunKey* = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
DefaultHotkeyModifiers* = MOD_CONTROL or MOD_ALT ## Ctrl+Alt
@@ -116,7 +116,7 @@ proc defaultConfig*(lang: Lang = langFr): Config =
# ---------------------------------------------------------------------------
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
## le répertoire de l'exécutable lui-même.
let exeDir = getAppFilename().parentDir
@@ -254,7 +254,7 @@ proc saveConfig*(cfg: Config) =
# ---------------------------------------------------------------------------
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
if RegOpenKeyEx(HKEY_CURRENT_USER, RunKey, 0, KEY_QUERY_VALUE, &hKey) == ERROR_SUCCESS:
var dataType: DWORD = 0
+290 -529
View File
@@ -1,131 +1,30 @@
## 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.
## Interfaces graphiques basées sur wNim (framework GUI Windows natif en Nim).
## Remplace l'ancienne couche Win32 brute pour un rendu plus propre et moderne :
## panneau de réglages, gestion des rappels santé, assistant de premier
## lancement et boîte de statistiques.
import winim/lean
import winim/inc/commctrl
import std/strutils
import wnim
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)
# État partagé (les dialogues sont modaux, donc mono-thread, donc sûrs)
# ---------------------------------------------------------------------------
var
dlgCfg: Config
dlgClosed: bool
dlgSaved: bool
dlgHwnd: HWND
dlgParent: HWND
# Assistant
wizStep: int
wizCfg: Config
dlgReminders: seq[HealthReminder]
# ---------------------------------------------------------------------------
# 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,
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) =
proc showStatsDialog*(cfg: Config, st: Stats) =
var msg = S.statsToday & " : " & $st.completedToday()
msg &= "\n" & S.statsWeek & " : " & $st.completedThisWeek()
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
if done >= cfg.dailyGoal:
msg &= " ✓"
msg &= "\n\n---\n" & S.statsDayWord & " " & "count"
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)
discard MessageDialog(message=msg, caption=S.statsTitle, style=wOK).display()
# ---------------------------------------------------------------------------
# Boîte de dialogue des rappels santé : liste modifiable (ajouter / modifier /
# supprimer / activer-désactiver), avec invite de saisie pour le texte.
# Saisie d'une ligne de texte (wTextEntryDialog)
# ---------------------------------------------------------------------------
var
commonControlsInited = false
remListHwnd: HWND
remWorking: seq[HealthReminder] ## Copie de travail (validée à la fermeture).
promptResult: string
promptDone: bool
proc promptText(owner: wWindow, title, initial: string): string =
let dlg = TextEntryDialog(owner=owner, message=S.reminderTextPrompt,
caption=title, value=initial)
result = dlg.display().strip()
proc ensureCommonControls() =
if not commonControlsInited:
var icc: TINITCOMMONCONTROLSEX
icc.dwSize = DWORD(sizeof(TINITCOMMONCONTROLSEX))
icc.dwICC = ICC_LISTVIEW_CLASSES
discard InitCommonControlsEx(&icc)
commonControlsInited = true
# ---------------------------------------------------------------------------
# Gestion des rappels santé (liste + ajout / modification / suppression)
# ---------------------------------------------------------------------------
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
proc showRemindersDialog*(owner: wWindow, cfg: var Config) =
dlgReminders = cfg.healthReminders
dlgSaved = false
ensureCommonControls()
let w = 440'i32
let h = 340'i32
let frame = Frame(owner=owner, title=S.settingsHealth, style=wDefaultDialogStyle)
let panel = Panel(frame)
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 list = ListCtrl(panel, style=wLcReport or wLcSingleSel)
let btnAdd = Button(panel, label=S.reminderAdd)
let btnEdit = Button(panel, label=S.reminderEdit)
let btnRemove = Button(panel, label=S.reminderRemove)
let chkEnabled = CheckBox(panel, label=S.settingsHealth)
let btnOk = Button(panel, label=S.btnOk)
let btnCancel = Button(panel, label=S.btnCancel)
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)
proc refresh() =
list.clearAll()
for r in dlgReminders:
list.appendItem(r.text)
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)
proc selectedIndex(): int =
list.getNextItem(-1, wListNextAll, wListStateSelected)
# 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))
proc updateEnabled() =
let idx = selectedIndex()
if idx >= 0 and idx < dlgReminders.len:
chkEnabled.setValue(dlgReminders[idx].enabled)
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)
list.appendColumn("", 320)
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)
btnAdd.wEvent_Button do ():
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
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
if not IsDialogMessage(hwnd, &m):
TranslateMessage(&m)
DispatchMessage(&m)
btnRemove.wEvent_Button do ():
let idx = selectedIndex()
if idx >= 0 and idx < dlgReminders.len:
dlgReminders.delete(idx)
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:
cfg.healthReminders = remWorking
cfg.healthReminders = dlgReminders
# ---------------------------------------------------------------------------
# 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.
proc showSettingsDialog*(cfg: var Config): bool =
dlgCfg = cfg
dlgClosed = false
dlgSaved = false
let inst = GetModuleHandleW(nil)
let w = 420'i32
let h = 430'i32
let frame = Frame(title=S.settingsTitle, style=wDefaultDialogStyle)
let panel = Panel(frame)
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 lblWork = StaticText(panel, label=S.settingsWork)
let lblShort = StaticText(panel, label=S.settingsShortBreak)
let lblLong = StaticText(panel, label=S.settingsLongBreak)
let lblCycles = StaticText(panel, label=S.settingsCycles)
let lblGoal = StaticText(panel, label=S.settingsGoal)
let lblLang = StaticText(panel, label=S.settingsLanguage)
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)
let editWork = TextCtrl(panel)
let editShort = TextCtrl(panel)
let editLong = TextCtrl(panel)
let editCycles = TextCtrl(panel)
let editGoal = TextCtrl(panel)
# --- 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)
let chkSound = CheckBox(panel, label=S.settingsSound)
let chkDnd = CheckBox(panel, label=S.settingsDoNotDisturb)
let chkAutoStart = CheckBox(panel, label=S.settingsAutoStart)
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)
let cboLang = ComboBox(panel, style=wCbReadOnly)
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)
let btnReminders = Button(panel, label=S.settingsEditReminders)
let btnSave = Button(panel, label=S.settingsSave)
let btnCancel = Button(panel, label=S.settingsCancel)
y += 28
discard label(S.settingsCycles, 12, y + 3, 180, 18, 0, hwnd, inst)
discard editNum(200, y, 60, 22, IdEditCycles, hwnd, inst)
cboLang.append(["Auto", "Français", "English"])
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)
# Pré-remplissage.
editWork.setValue($cfg.workMin)
editShort.setValue($cfg.shortBreakMin)
editLong.setValue($cfg.longBreakMin)
editCycles.setValue($cfg.cyclesBeforeLongBreak)
editGoal.setValue($cfg.dailyGoal)
chkSound.setValue(cfg.soundEnabled)
chkDnd.setValue(cfg.doNotDisturb)
chkAutoStart.setValue(cfg.autoStart)
case cfg.lang
of "fr": setChecked(hwnd, IdRadioFr, true)
of "en": setChecked(hwnd, IdRadioEn, true)
else: setChecked(hwnd, IdRadioAuto, true)
of "fr": cboLang.setSelection(1)
of "en": cboLang.setSelection(2)
else: cboLang.setSelection(0)
discard SetFocus(GetDlgItem(hwnd, IdEditWork))
result = hwnd
proc parseIntOr(text: string, dflt: int): int =
try: result = parseInt(text)
except ValueError: result = dflt
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)
btnSave.wEvent_Button do ():
dlgCfg.workMin = parseIntOr(editWork.getValue(), dlgCfg.workMin)
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).
var m: MSG
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
if not IsDialogMessage(hwnd, &m):
TranslateMessage(&m)
DispatchMessage(&m)
btnCancel.wEvent_Button do ():
frame.close()
btnReminders.wEvent_Button do ():
showRemindersDialog(frame, dlgCfg)
# 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:
cfg = dlgCfg
return dlgSaved
result = 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
dlgCfg = cfg
dlgSaved = false
let inst = GetModuleHandleW(nil)
let w = 520'i32
let h = 360'i32
var step = 0
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 frame = Frame(title=S.wizardWelcomeTitle, style=wDefaultDialogStyle)
let panel = Panel(frame)
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)
let text = StaticText(panel, label="")
let radio1 = RadioButton(panel, label="")
let radio2 = RadioButton(panel, label="")
let radio3 = RadioButton(panel, label="")
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
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
if not IsDialogMessage(hwnd, &m):
TranslateMessage(&m)
DispatchMessage(&m)
proc showAll(b: bool) =
text.show(b)
radio1.show(b); radio2.show(b); radio3.show(b)
cboProfile.show(b)
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:
cfg = wizCfg
return dlgSaved
cfg = dlgCfg
result = dlgSaved
+2 -2
View File
@@ -43,7 +43,7 @@ proc showFloating*() =
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](floatingProc)
wc.hInstance = inst
wc.lpszClassName = L"TrayTempoFloatingClass"
wc.lpszClassName = L"NimPulseFloatingClass"
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
discard RegisterClass(&wc)
@@ -53,7 +53,7 @@ proc showFloating*() =
let scrH = GetSystemMetrics(SM_CYSCREEN)
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,
scrW - w - 24, scrH - h - 80, w, h,
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).
##
## 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 config
@@ -130,7 +130,7 @@ type Strings* = object
# ---------------------------------------------------------------------------
const fr* = Strings(
appName: "TrayTempo",
appName: "NimPulse",
phaseIdle: "Inactif",
phaseWorking: "Travail",
@@ -138,8 +138,8 @@ const fr* = Strings(
phaseLongBreak: "Pause longue",
phasePaused: "En pause",
tipIdle: "TrayTempo - Inactif",
tipPaused: "TrayTempo - En pause",
tipIdle: "NimPulse - Inactif",
tipPaused: "NimPulse - En pause",
menuStart: "Démarrer",
menuResume: "Reprendre",
@@ -165,7 +165,7 @@ const fr* = Strings(
breakShort: "courte",
breakLong: "longue",
statsTitle: "Statistiques TrayTempo",
statsTitle: "Statistiques NimPulse",
statsToday: "Pomodoros aujourd'hui",
statsWeek: "Pomodoros (7 jours)",
statsStreak: "Série",
@@ -176,8 +176,8 @@ const fr* = Strings(
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 " &
wizardWelcomeTitle: "Bienvenue dans NimPulse",
wizardWelcomeBody: "Configurons NimPulse en quelques étapes simples pour que " &
"vous soyez productif tout en prenant soin de votre santé.",
wizardLangTitle: "Langue / Language",
wizardLangPrompt: "Choisissez votre langue :",
@@ -186,10 +186,10 @@ const fr* = Strings(
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. " &
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.",
settingsTitle: "Paramètres TrayTempo",
settingsTitle: "Paramètres NimPulse",
settingsDurations: "Durées",
settingsWork: "Travail",
settingsShortBreak: "Pause courte",
@@ -232,7 +232,7 @@ const fr* = Strings(
)
const en* = Strings(
appName: "TrayTempo",
appName: "NimPulse",
phaseIdle: "Idle",
phaseWorking: "Working",
@@ -240,8 +240,8 @@ const en* = Strings(
phaseLongBreak: "Long break",
phasePaused: "Paused",
tipIdle: "TrayTempo - Idle",
tipPaused: "TrayTempo - Paused",
tipIdle: "NimPulse - Idle",
tipPaused: "NimPulse - Paused",
menuStart: "Start",
menuResume: "Resume",
@@ -267,7 +267,7 @@ const en* = Strings(
breakShort: "short",
breakLong: "long",
statsTitle: "TrayTempo Statistics",
statsTitle: "NimPulse Statistics",
statsToday: "Pomodoros today",
statsWeek: "Pomodoros (7 days)",
statsStreak: "Streak",
@@ -278,8 +278,8 @@ const en* = Strings(
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 " &
wizardWelcomeTitle: "Welcome to NimPulse",
wizardWelcomeBody: "Let's set up NimPulse in a few simple steps so you can " &
"be productive while taking care of your health.",
wizardLangTitle: "Language",
wizardLangPrompt: "Choose your language:",
@@ -288,10 +288,10 @@ const en* = Strings(
wizardHealthTitle: "Health reminders",
wizardHealthPrompt: "Which reminders do you want during your breaks?",
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.",
settingsTitle: "TrayTempo Settings",
settingsTitle: "NimPulse Settings",
settingsDurations: "Durations",
settingsWork: "Work",
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
## 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
## nim c -d:release --app:gui src/nimpulse.nim
import std/os
import std/typedthreads
import std/strutils
import winim/lean
import winim/inc/shellapi
import wnim
import config
import timer
@@ -26,7 +27,7 @@ import floating
const
TimerId = 1'i64
HotkeyId = 1'i32
SingleInstanceMutexName = "TrayTempo_SingleInstance_Mutex"
SingleInstanceMutexName = "NimPulse_SingleInstance_Mutex"
WmUpdateAvailable = WM_APP + 2
# ---------------------------------------------------------------------------
@@ -93,7 +94,7 @@ proc quitApp() =
proc openSettings() =
## Ouvre le panneau de réglages graphique.
if showSettingsDialog(gHwnd, gCfg):
if showSettingsDialog(gCfg):
saveConfig(gCfg)
setLang(resolveLang(gCfg))
discard setAutostart(gCfg.autoStart)
@@ -102,7 +103,7 @@ proc openSettings() =
showToast(gHwnd, S.appName, S.settingsSaved)
proc showStatsDialog() =
dialogs.showStatsDialog(gHwnd, gCfg, gStats)
dialogs.showStatsDialog(gCfg, gStats)
proc exportStatsCsv() =
## Exporte l'historique en CSV dans le répertoire de configuration.
@@ -230,14 +231,17 @@ proc isAlreadyRunning(): bool =
proc main() =
if isAlreadyRunning():
discard MessageBox(0, "TrayTempo est déjà en cours d'exécution.",
"TrayTempo", MB_OK or MB_ICONINFORMATION)
discard MessageBox(0, "NimPulse est déjà en cours d'exécution.",
"NimPulse", MB_OK or MB_ICONINFORMATION)
return
gCfg = loadConfig()
gStats = loadStats()
gTimer = initTimer()
# Initialise le framework wNim (contrôles communs, OLE, etc.) pour les dialogues.
discard App()
# Premier lancement : assistant de configuration guidée.
let firstRun = not fileExists(configPath())
if firstRun:
@@ -255,17 +259,17 @@ proc main() =
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](wndProc)
wc.hInstance = hInstance
wc.lpszClassName = L"TrayTempoWindowClass"
wc.lpszClassName = L"NimPulseWindowClass"
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",
gHwnd = CreateWindowExW(0, L"NimPulseWindowClass", L"NimPulse",
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)
"NimPulse", MB_ICONERROR)
return
# Icône dans le tray.
+4 -4
View File
@@ -8,7 +8,7 @@ import std/[unittest, os]
import ../src/config
# 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):
removeDir(tmpApd)
putEnv("APPDATA", tmpApd)
@@ -102,11 +102,11 @@ suite "config":
writeFile(marker, "")
check configDir() == exeDir
removeFile(marker)
check configDir() == (getEnv("APPDATA") / "TrayTempo")
check configDir() == (getEnv("APPDATA") / "NimPulse")
test "acquireSingleInstance detects a second lock":
check not acquireSingleInstance("TrayTempo_Test_Mutex_0.2.0")
check acquireSingleInstance("TrayTempo_Test_Mutex_0.2.0")
check not acquireSingleInstance("NimPulse_Test_Mutex_0.2.0")
check acquireSingleInstance("NimPulse_Test_Mutex_0.2.0")
suite "autostart registry":
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()
st.daily["2026-08-13"] = 3
st.daily["2026-08-14"] = 5
let path = getTempDir() / "traytempo_test_export.csv"
let path = getTempDir() / "nimpulse_test_export.csv"
check st.exportCsv(path)
let content = readFile(path)
check content.contains("date,count")
@@ -91,7 +91,7 @@ suite "stats":
suite "stats persistence":
test "saveStats then loadStats round-trips":
# Isole l'écriture disque dans un APPDATA temporaire.
let tmpApd = getTempDir() / "traytempo_test_stats"
let tmpApd = getTempDir() / "nimpulse_test_stats"
if dirExists(tmpApd):
removeDir(tmpApd)
putEnv("APPDATA", tmpApd)
+3 -3
View File
@@ -19,9 +19,9 @@ proc createTestWindow(): HWND =
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](DefWindowProcW)
wc.hInstance = hInstance
wc.lpszClassName = L"TrayTempoTestClass"
wc.lpszClassName = L"NimPulseTestClass"
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)
suite "tray integration":
@@ -34,7 +34,7 @@ suite "tray integration":
check phaseIcon(phase) != 0
# 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.
updateTrayIcon(hwnd, phaseIcon(phWorking), "Travail: 25:00")