v0.3.0 : migration des dialogues vers wNim + renommage NimPulse + installeur une-ligne
This commit is contained in:
@@ -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
@@ -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
@@ -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}"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user