TrayTempo v0.2.0 : minuteur Pomodoro Windows en Nim avec interface intuitive
This commit is contained in:
+298
@@ -0,0 +1,298 @@
|
||||
## config.nim
|
||||
## ===========
|
||||
## Chargement / sauvegarde de la configuration (JSON dans %APPDATA%\TrayTempo)
|
||||
## et gestion du démarrage automatique (clé de registre Run).
|
||||
##
|
||||
## Le module définit les types de données partagés (Config, Profile,
|
||||
## HealthReminder) ainsi que les chemins des fichiers persistants.
|
||||
|
||||
import std/[json, os]
|
||||
import winim/lean
|
||||
import winim/inc/winreg
|
||||
|
||||
const
|
||||
AppName* = "TrayTempo"
|
||||
AppVersion* = "0.2.0"
|
||||
RunKey* = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
|
||||
DefaultHotkeyModifiers* = MOD_CONTROL or MOD_ALT ## Ctrl+Alt
|
||||
DefaultHotkeyVk* = 0x50'i32 ## touche 'P'
|
||||
|
||||
type
|
||||
## Langue de l'interface.
|
||||
Lang* = enum
|
||||
langAuto, langFr, langEn
|
||||
|
||||
## Un rappel santé affiché pendant les pauses.
|
||||
HealthReminder* = object
|
||||
text*: string ## Texte du rappel.
|
||||
enabled*: bool ## Activé / désactivé par l'utilisateur.
|
||||
|
||||
## Un profil Pomodoro (durées + cycles).
|
||||
Profile* = object
|
||||
name*: string
|
||||
workMin*: int
|
||||
shortBreakMin*: int
|
||||
longBreakMin*: int
|
||||
cycles*: int ## Nombre de cycles avant une pause longue.
|
||||
|
||||
## Configuration complète de l'application.
|
||||
Config* = object
|
||||
workMin*: int ## Durée de travail par défaut.
|
||||
shortBreakMin*: int ## Pause courte par défaut.
|
||||
longBreakMin*: int ## Pause longue par défaut.
|
||||
cyclesBeforeLongBreak*: int ## Cycles avant pause longue.
|
||||
soundEnabled*: bool ## Son à la fin d'une période.
|
||||
doNotDisturb*: bool ## Suspend les rappels santé (garde le timer).
|
||||
autoStart*: bool ## Démarrage avec Windows.
|
||||
activeProfile*: string ## Nom du profil actif ("" => défaut).
|
||||
lang*: string ## "auto", "fr" ou "en".
|
||||
dailyGoal*: int ## Objectif de pomodoros par jour (0 = aucun).
|
||||
hotkeyModifiers*: int32 ## Modificateurs du raccourci global.
|
||||
hotkeyVk*: int32 ## Code de touche virtuelle du raccourci.
|
||||
soundName*: string ## Alias sonore système (fin de période).
|
||||
floatingWindow*: bool ## Affiche une petite fenêtre flottante.
|
||||
profiles*: seq[Profile]
|
||||
healthReminders*: seq[HealthReminder]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valeurs par défaut
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc defaultReminders*(lang: Lang = langFr): seq[HealthReminder] =
|
||||
if lang == langFr:
|
||||
@[
|
||||
HealthReminder(text: "Lève-toi et marche 2 minutes", enabled: true),
|
||||
HealthReminder(text: "Va boire un verre d'eau", enabled: true),
|
||||
HealthReminder(text: "Fais 10 push-ups", enabled: true),
|
||||
HealthReminder(text: "Règle 20-20-20 : regarde à 20 pieds pendant 20 secondes", enabled: true),
|
||||
HealthReminder(text: "Étire ton cou et tes épaules", enabled: true),
|
||||
]
|
||||
else:
|
||||
@[
|
||||
HealthReminder(text: "Stand up and walk for 2 minutes", enabled: true),
|
||||
HealthReminder(text: "Go drink a glass of water", enabled: true),
|
||||
HealthReminder(text: "Do 10 push-ups", enabled: true),
|
||||
HealthReminder(text: "20-20-20 rule: look 20 feet away for 20 seconds", enabled: true),
|
||||
HealthReminder(text: "Stretch your neck and shoulders", enabled: true),
|
||||
]
|
||||
|
||||
proc defaultProfiles*(lang: Lang = langFr): seq[Profile] =
|
||||
if lang == langFr:
|
||||
@[
|
||||
Profile(name: "Classique", workMin: 25, shortBreakMin: 5, longBreakMin: 15, cycles: 4),
|
||||
Profile(name: "Deep Work", workMin: 50, shortBreakMin: 10, longBreakMin: 20, cycles: 4),
|
||||
Profile(name: "Sprint", workMin: 15, shortBreakMin: 3, longBreakMin: 10, cycles: 6),
|
||||
]
|
||||
else:
|
||||
@[
|
||||
Profile(name: "Classic", workMin: 25, shortBreakMin: 5, longBreakMin: 15, cycles: 4),
|
||||
Profile(name: "Deep Work", workMin: 50, shortBreakMin: 10, longBreakMin: 20, cycles: 4),
|
||||
Profile(name: "Sprint", workMin: 15, shortBreakMin: 3, longBreakMin: 10, cycles: 6),
|
||||
]
|
||||
|
||||
proc defaultConfig*(lang: Lang = langFr): Config =
|
||||
let profiles = defaultProfiles(lang)
|
||||
Config(
|
||||
workMin: 25,
|
||||
shortBreakMin: 5,
|
||||
longBreakMin: 15,
|
||||
cyclesBeforeLongBreak: 4,
|
||||
soundEnabled: true,
|
||||
doNotDisturb: false,
|
||||
autoStart: false,
|
||||
activeProfile: profiles[0].name,
|
||||
lang: "auto",
|
||||
dailyGoal: 0,
|
||||
hotkeyModifiers: DefaultHotkeyModifiers,
|
||||
hotkeyVk: DefaultHotkeyVk,
|
||||
soundName: "SystemAsterisk",
|
||||
floatingWindow: false,
|
||||
profiles: profiles,
|
||||
healthReminders: defaultReminders(lang),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chemins persistants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc configDir*(): string =
|
||||
## Répertoire de configuration : `%APPDATA%\TrayTempo`, sauf en mode
|
||||
## portable (fichier `portable` placé à côté de l'exécutable) où l'on utilise
|
||||
## le répertoire de l'exécutable lui-même.
|
||||
let exeDir = getAppFilename().parentDir
|
||||
if fileExists(exeDir / "portable"):
|
||||
return exeDir
|
||||
getEnv("APPDATA") / AppName
|
||||
|
||||
proc configPath*(): string =
|
||||
configDir() / "config.json"
|
||||
|
||||
proc statsPath*(): string =
|
||||
configDir() / "stats.json"
|
||||
|
||||
proc ensureConfigDir*() =
|
||||
## Crée le répertoire de configuration s'il n'existe pas.
|
||||
createDir(configDir())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Résolution du profil actif
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc getActiveProfile*(cfg: Config): Profile =
|
||||
## Retourne le profil actif, ou un profil synthétique basé sur les valeurs
|
||||
## par défaut si aucun profil ne correspond.
|
||||
for p in cfg.profiles:
|
||||
if p.name == cfg.activeProfile:
|
||||
return p
|
||||
Profile(
|
||||
name: "Défaut",
|
||||
workMin: cfg.workMin,
|
||||
shortBreakMin: cfg.shortBreakMin,
|
||||
longBreakMin: cfg.longBreakMin,
|
||||
cycles: cfg.cyclesBeforeLongBreak,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chargement / sauvegarde
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc loadConfig*(): Config =
|
||||
## Charge la configuration depuis le disque. En cas d'erreur ou de fichier
|
||||
## manquant, on conserve des valeurs par défaut (jamais de crash au boot).
|
||||
result = defaultConfig()
|
||||
try:
|
||||
let path = configPath()
|
||||
if not fileExists(path):
|
||||
return
|
||||
let n = parseFile(path)
|
||||
|
||||
result.workMin = n.getOrDefault("workMin").getInt(result.workMin)
|
||||
result.shortBreakMin = n.getOrDefault("shortBreakMin").getInt(result.shortBreakMin)
|
||||
result.longBreakMin = n.getOrDefault("longBreakMin").getInt(result.longBreakMin)
|
||||
result.cyclesBeforeLongBreak =
|
||||
n.getOrDefault("cyclesBeforeLongBreak").getInt(result.cyclesBeforeLongBreak)
|
||||
result.soundEnabled = n.getOrDefault("soundEnabled").getBool(result.soundEnabled)
|
||||
result.doNotDisturb = n.getOrDefault("doNotDisturb").getBool(result.doNotDisturb)
|
||||
result.autoStart = n.getOrDefault("autoStart").getBool(result.autoStart)
|
||||
result.activeProfile = n.getOrDefault("activeProfile").getStr(result.activeProfile)
|
||||
result.lang = n.getOrDefault("lang").getStr(result.lang)
|
||||
result.dailyGoal = n.getOrDefault("dailyGoal").getInt(result.dailyGoal)
|
||||
result.hotkeyModifiers = int32(n.getOrDefault("hotkeyModifiers").getInt(result.hotkeyModifiers.int))
|
||||
result.hotkeyVk = int32(n.getOrDefault("hotkeyVk").getInt(result.hotkeyVk.int))
|
||||
result.soundName = n.getOrDefault("soundName").getStr(result.soundName)
|
||||
result.floatingWindow = n.getOrDefault("floatingWindow").getBool(result.floatingWindow)
|
||||
|
||||
let pro = n.getOrDefault("profiles")
|
||||
if not pro.isNil and pro.kind == JArray:
|
||||
result.profiles = @[]
|
||||
for p in pro:
|
||||
result.profiles.add Profile(
|
||||
name: p.getOrDefault("name").getStr(),
|
||||
workMin: p.getOrDefault("workMin").getInt(25),
|
||||
shortBreakMin: p.getOrDefault("shortBreakMin").getInt(5),
|
||||
longBreakMin: p.getOrDefault("longBreakMin").getInt(15),
|
||||
cycles: p.getOrDefault("cycles").getInt(4),
|
||||
)
|
||||
|
||||
let rem = n.getOrDefault("healthReminders")
|
||||
if not rem.isNil and rem.kind == JArray:
|
||||
result.healthReminders = @[]
|
||||
for r in rem:
|
||||
result.healthReminders.add HealthReminder(
|
||||
text: r.getOrDefault("text").getStr(),
|
||||
enabled: r.getOrDefault("enabled").getBool(true),
|
||||
)
|
||||
except:
|
||||
# Fichier corrompu : on garde les valeurs par défaut.
|
||||
discard
|
||||
|
||||
proc saveConfig*(cfg: Config) =
|
||||
## Sauvegarde la configuration au format JSON (pretty-printé).
|
||||
try:
|
||||
ensureConfigDir()
|
||||
var n = newJObject()
|
||||
n["workMin"] = %cfg.workMin
|
||||
n["shortBreakMin"] = %cfg.shortBreakMin
|
||||
n["longBreakMin"] = %cfg.longBreakMin
|
||||
n["cyclesBeforeLongBreak"] = %cfg.cyclesBeforeLongBreak
|
||||
n["soundEnabled"] = %cfg.soundEnabled
|
||||
n["doNotDisturb"] = %cfg.doNotDisturb
|
||||
n["autoStart"] = %cfg.autoStart
|
||||
n["activeProfile"] = %cfg.activeProfile
|
||||
n["lang"] = %cfg.lang
|
||||
n["dailyGoal"] = %cfg.dailyGoal
|
||||
n["hotkeyModifiers"] = %cfg.hotkeyModifiers
|
||||
n["hotkeyVk"] = %cfg.hotkeyVk
|
||||
n["soundName"] = %cfg.soundName
|
||||
n["floatingWindow"] = %cfg.floatingWindow
|
||||
|
||||
var pro = newJArray()
|
||||
for p in cfg.profiles:
|
||||
var o = newJObject()
|
||||
o["name"] = %p.name
|
||||
o["workMin"] = %p.workMin
|
||||
o["shortBreakMin"] = %p.shortBreakMin
|
||||
o["longBreakMin"] = %p.longBreakMin
|
||||
o["cycles"] = %p.cycles
|
||||
pro.add o
|
||||
n["profiles"] = pro
|
||||
|
||||
var rem = newJArray()
|
||||
for r in cfg.healthReminders:
|
||||
var o = newJObject()
|
||||
o["text"] = %r.text
|
||||
o["enabled"] = %r.enabled
|
||||
rem.add o
|
||||
n["healthReminders"] = rem
|
||||
|
||||
writeFile(configPath(), n.pretty)
|
||||
except:
|
||||
discard
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Démarrage automatique (registre)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc isAutostartEnabled*(): bool =
|
||||
## Vérifie la présence de la valeur TrayTempo dans la clé Run.
|
||||
var hKey: HKEY
|
||||
if RegOpenKeyEx(HKEY_CURRENT_USER, RunKey, 0, KEY_QUERY_VALUE, &hKey) == ERROR_SUCCESS:
|
||||
var dataType: DWORD = 0
|
||||
var dataSize: DWORD = 0
|
||||
let ok = RegQueryValueEx(hKey, AppName, nil, &dataType, nil, &dataSize) == ERROR_SUCCESS
|
||||
RegCloseKey(hKey)
|
||||
return ok and dataSize > 0
|
||||
return false
|
||||
|
||||
proc setAutostart*(enable: bool): bool =
|
||||
## Active (écrit la clé) ou désactive (supprime la clé) le lancement au
|
||||
## démarrage. Retourne true en cas de succès.
|
||||
result = false
|
||||
var hKey: HKEY
|
||||
if RegOpenKeyEx(HKEY_CURRENT_USER, RunKey, 0,
|
||||
KEY_SET_VALUE or KEY_QUERY_VALUE, &hKey) == ERROR_SUCCESS:
|
||||
if enable:
|
||||
let exePath = getAppFilename()
|
||||
let value = "\"" & exePath & "\""
|
||||
let wval = +$value # UTF-8 -> UTF-16
|
||||
let cbData = DWORD((wval.len + 1) * 2) # inclut le null terminal
|
||||
if RegSetValueEx(hKey, AppName, 0, REG_SZ,
|
||||
cast[ptr BYTE](&wval), cbData) == ERROR_SUCCESS:
|
||||
result = true
|
||||
else:
|
||||
if RegDeleteValue(hKey, AppName) == ERROR_SUCCESS:
|
||||
result = true
|
||||
RegCloseKey(hKey)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mono-instance (mutex nommé)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var gInstanceMutex: HANDLE
|
||||
|
||||
proc acquireSingleInstance*(name: string): bool =
|
||||
## Tente d'acquérir un mutex nommé global. Retourne true si une autre
|
||||
## instance détient déjà ce mutex (application déjà lancée). Le handle est
|
||||
## conservé dans `gInstanceMutex` pour toute la durée du processus.
|
||||
gInstanceMutex = CreateMutexW(nil, FALSE, +$name)
|
||||
result = GetLastError() == ERROR_ALREADY_EXISTS
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
## dialogs.nim
|
||||
## ============
|
||||
## Interfaces graphiques natives (Win32) construites sans fichier de ressource :
|
||||
## panneau de réglages, édition des rappels santé, assistant de premier
|
||||
## lancement et boîte de dialogue de statistiques.
|
||||
##
|
||||
## Ces dialogues sont modaux et utilisent des fenêtres/contrôles créés à la
|
||||
## volée via CreateWindowExW. Les valeurs sont lues/écrites dans un objet
|
||||
## `Config` passé par référence.
|
||||
|
||||
import winim/lean
|
||||
import winim/inc/commctrl
|
||||
import std/strutils
|
||||
import config
|
||||
import i18n
|
||||
import stats
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identifiants des contrôles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const
|
||||
# Réglages
|
||||
IdEditWork = 2001
|
||||
IdEditShortBreak = 2002
|
||||
IdEditLongBreak = 2003
|
||||
IdEditCycles = 2004
|
||||
IdChkSound = 2005
|
||||
IdChkDnd = 2006
|
||||
IdChkAutoStart = 2007
|
||||
IdEditGoal = 2008
|
||||
IdRadioAuto = 2009
|
||||
IdRadioFr = 2010
|
||||
IdRadioEn = 2011
|
||||
IdBtnSave = 2012
|
||||
IdBtnCancel = 2013
|
||||
IdBtnReminders = 2014
|
||||
|
||||
# Rappels santé (liste + boutons)
|
||||
IdRemList = 2100
|
||||
IdRemAdd = 2101
|
||||
IdRemEdit = 2102
|
||||
IdRemRemove = 2103
|
||||
IdRemOk = 2104
|
||||
IdRemCancel = 2105
|
||||
|
||||
# Invite de saisie de texte (ajout/édition de rappel)
|
||||
IdPromptEdit = 2110
|
||||
IdPromptOk = 2111
|
||||
IdPromptCancel = 2112
|
||||
|
||||
# Assistant
|
||||
IdWizBack = 2201
|
||||
IdWizNext = 2202
|
||||
IdWizRadioBase = 2203
|
||||
IdWizChkBase = 2250
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# État global des dialogues (ils sont modaux et mono-thread, donc sûrs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var
|
||||
dlgCfg: Config
|
||||
dlgClosed: bool
|
||||
dlgSaved: bool
|
||||
dlgHwnd: HWND
|
||||
dlgParent: HWND
|
||||
|
||||
# Assistant
|
||||
wizStep: int
|
||||
wizCfg: Config
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers de création de contrôles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc makeCtrl(cls, text: string, style: int, x, y, w, h: int, id: int32,
|
||||
parent: HWND, hInstance: HINSTANCE): HWND =
|
||||
CreateWindowExW(0, cls, text, DWORD(style), int32(x), int32(y), int32(w),
|
||||
int32(h), parent, cast[HMENU](id), hInstance, nil)
|
||||
|
||||
proc label(text: string, x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE): HWND =
|
||||
makeCtrl("STATIC", text, WS_CHILD or WS_VISIBLE or SS_LEFT, x, y, w, h, id, parent, inst)
|
||||
|
||||
proc editNum(x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE): HWND =
|
||||
makeCtrl("EDIT", "", WS_CHILD or WS_VISIBLE or WS_BORDER or WS_TABSTOP or ES_NUMBER,
|
||||
x, y, w, h, id, parent, inst)
|
||||
|
||||
proc checkbox(text: string, x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE): HWND =
|
||||
makeCtrl("BUTTON", text, WS_CHILD or WS_VISIBLE or BS_AUTOCHECKBOX or WS_TABSTOP,
|
||||
x, y, w, h, id, parent, inst)
|
||||
|
||||
proc radio(text: string, x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE,
|
||||
group: bool): HWND =
|
||||
let style = WS_CHILD or WS_VISIBLE or BS_AUTORADIOBUTTON or WS_TABSTOP or
|
||||
(if group: WS_GROUP else: 0)
|
||||
makeCtrl("BUTTON", text, style, x, y, w, h, id, parent, inst)
|
||||
|
||||
proc button(text: string, x, y, w, h: int, id: int32, parent: HWND, inst: HINSTANCE): HWND =
|
||||
makeCtrl("BUTTON", text, WS_CHILD or WS_VISIBLE or WS_TABSTOP, x, y, w, h, id, parent, inst)
|
||||
|
||||
proc getEditInt(hwnd: HWND, id: int32, dflt: int): int =
|
||||
var ok: BOOL
|
||||
let v = GetDlgItemInt(hwnd, id, &ok, TRUE)
|
||||
if ok != 0: int(v) else: dflt
|
||||
|
||||
proc setEditInt(hwnd: HWND, id: int32, value: int) =
|
||||
discard SetDlgItemInt(hwnd, id, UINT(value), TRUE)
|
||||
|
||||
proc isChecked(hwnd: HWND, id: int32): bool =
|
||||
SendMessage(GetDlgItem(hwnd, id), BM_GETCHECK, 0, 0) == BST_CHECKED
|
||||
|
||||
proc setChecked(hwnd: HWND, id: int32, value: bool) =
|
||||
discard SendMessage(GetDlgItem(hwnd, id), BM_SETCHECK,
|
||||
if value: BST_CHECKED else: BST_UNCHECKED, 0)
|
||||
|
||||
proc centerWindow(hwnd: HWND, w, h: int32) =
|
||||
let scrW = GetSystemMetrics(SM_CXSCREEN)
|
||||
let scrH = GetSystemMetrics(SM_CYSCREEN)
|
||||
let x = (scrW - w) div 2
|
||||
let y = (scrH - h) div 2
|
||||
discard SetWindowPos(hwnd, 0, x, y, w, h, SWP_NOZORDER)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boîte de dialogue des statistiques
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc showStatsDialog*(hwnd: HWND, cfg: Config, st: Stats) =
|
||||
var msg = S.statsToday & " : " & $st.completedToday()
|
||||
msg &= "\n" & S.statsWeek & " : " & $st.completedThisWeek()
|
||||
msg &= "\n" & S.statsStreak & " : " & $st.streak() & " " & S.statsDays
|
||||
msg &= "\n" & S.statsTotal & " : " & $st.totalPomodoros()
|
||||
if cfg.dailyGoal > 0:
|
||||
let done = st.completedToday()
|
||||
msg &= "\n\n" & S.statsGoal & " : " & $done & " / " & $cfg.dailyGoal
|
||||
if done >= cfg.dailyGoal:
|
||||
msg &= " ✓"
|
||||
msg &= "\n\n---\n" & S.statsDayWord & " " & "count"
|
||||
for (date, n) in st.lastNDays(7):
|
||||
msg &= "\n" & date & " " & $n
|
||||
discard MessageBox(hwnd, msg, S.statsTitle, MB_OK or MB_ICONINFORMATION)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boîte de dialogue des rappels santé : liste modifiable (ajouter / modifier /
|
||||
# supprimer / activer-désactiver), avec invite de saisie pour le texte.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var
|
||||
commonControlsInited = false
|
||||
remListHwnd: HWND
|
||||
remWorking: seq[HealthReminder] ## Copie de travail (validée à la fermeture).
|
||||
promptResult: string
|
||||
promptDone: bool
|
||||
|
||||
proc ensureCommonControls() =
|
||||
if not commonControlsInited:
|
||||
var icc: TINITCOMMONCONTROLSEX
|
||||
icc.dwSize = DWORD(sizeof(TINITCOMMONCONTROLSEX))
|
||||
icc.dwICC = ICC_LISTVIEW_CLASSES
|
||||
discard InitCommonControlsEx(&icc)
|
||||
commonControlsInited = true
|
||||
|
||||
proc refreshReminderList(list: HWND) =
|
||||
## Reconstruit la liste à partir de la copie de travail.
|
||||
discard SendMessage(list, LVM_DELETEALLITEMS, 0, 0)
|
||||
for i, r in remWorking:
|
||||
let wtext = +$r.text
|
||||
var item: LVITEM
|
||||
item.mask = LVIF_TEXT
|
||||
item.iItem = int32(i)
|
||||
item.pszText = cast[LPWSTR](&wtext)
|
||||
ListView_InsertItem(list, &item)
|
||||
ListView_SetItemState(list, int32(i),
|
||||
UINT(INDEXTOSTATEIMAGEMASK(if r.enabled: 2 else: 1)),
|
||||
UINT(LVIS_STATEIMAGEMASK))
|
||||
|
||||
proc selectedReminderIndex(list: HWND): int =
|
||||
int(ListView_GetNextItem(list, -1, LVNI_SELECTED))
|
||||
|
||||
proc getEditText(hwnd: HWND, id: int32): string =
|
||||
## Lit le texte d'un contrôle EDIT et le convertit en chaîne UTF-8.
|
||||
var buf: array[512, WCHAR]
|
||||
let n = GetDlgItemTextW(hwnd, id, cast[LPWSTR](addr buf[0]), 512)
|
||||
if n > 0:
|
||||
result = $cast[WideCString](addr buf[0])
|
||||
|
||||
proc promptProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
|
||||
case msg
|
||||
of WM_COMMAND:
|
||||
let id = int(wParam and 0xFFFF)
|
||||
case id
|
||||
of IdPromptOk:
|
||||
promptResult = getEditText(hwnd, IdPromptEdit)
|
||||
promptDone = true
|
||||
DestroyWindow(hwnd)
|
||||
of IdPromptCancel:
|
||||
promptResult = ""
|
||||
promptDone = true
|
||||
DestroyWindow(hwnd)
|
||||
else: discard
|
||||
return 0
|
||||
of WM_DESTROY:
|
||||
promptDone = true
|
||||
return 0
|
||||
else: discard
|
||||
return DefWindowProc(hwnd, msg, wParam, lParam)
|
||||
|
||||
proc promptText(parent: HWND, inst: HINSTANCE, title, initial: string): string =
|
||||
## Petite boîte de saisie d'une ligne. Retourne "" si annulé.
|
||||
promptResult = ""
|
||||
promptDone = false
|
||||
|
||||
let w = 360'i32
|
||||
let h = 140'i32
|
||||
|
||||
var wc: WNDCLASSW
|
||||
wc.lpfnWndProc = cast[WNDPROC](promptProc)
|
||||
wc.hInstance = inst
|
||||
wc.lpszClassName = L"TrayTempoPromptClass"
|
||||
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
|
||||
discard RegisterClass(&wc)
|
||||
|
||||
let hwnd = CreateWindowExW(0, L"TrayTempoPromptClass", title,
|
||||
WS_CAPTION or WS_SYSMENU or WS_VISIBLE,
|
||||
0, 0, w, h, parent, 0, inst, nil)
|
||||
centerWindow(hwnd, w, h)
|
||||
|
||||
discard label(S.reminderTextPrompt, 12, 16, 320, 18, 0, hwnd, inst)
|
||||
discard makeCtrl("EDIT", initial, WS_CHILD or WS_VISIBLE or WS_BORDER or WS_TABSTOP,
|
||||
12, 38, w - 24, 24, IdPromptEdit, hwnd, inst)
|
||||
discard button(S.btnOk, w - 210, h - 50, 95, 28, IdPromptOk, hwnd, inst)
|
||||
discard button(S.btnCancel, w - 105, h - 50, 95, 28, IdPromptCancel, hwnd, inst)
|
||||
|
||||
var m: MSG
|
||||
while not promptDone and GetMessage(&m, 0, 0, 0) != 0:
|
||||
if not IsDialogMessage(hwnd, &m):
|
||||
TranslateMessage(&m)
|
||||
DispatchMessage(&m)
|
||||
|
||||
result = promptResult
|
||||
|
||||
proc remindersProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
|
||||
case msg
|
||||
of WM_COMMAND:
|
||||
let id = int(wParam and 0xFFFF)
|
||||
case id
|
||||
of IdRemAdd:
|
||||
let text = promptText(hwnd, GetModuleHandleW(nil), S.reminderNewTitle, "")
|
||||
if text.strip().len > 0:
|
||||
remWorking.add HealthReminder(text: text, enabled: true)
|
||||
refreshReminderList(remListHwnd)
|
||||
of IdRemEdit:
|
||||
let idx = selectedReminderIndex(remListHwnd)
|
||||
if idx >= 0 and idx < remWorking.len:
|
||||
let text = promptText(hwnd, GetModuleHandleW(nil), S.reminderEditTitle,
|
||||
remWorking[idx].text)
|
||||
if text.strip().len > 0:
|
||||
remWorking[idx].text = text
|
||||
refreshReminderList(remListHwnd)
|
||||
of IdRemRemove:
|
||||
let idx = selectedReminderIndex(remListHwnd)
|
||||
if idx >= 0 and idx < remWorking.len:
|
||||
remWorking.delete(idx)
|
||||
refreshReminderList(remListHwnd)
|
||||
of IdRemOk:
|
||||
for i in 0 ..< remWorking.len:
|
||||
remWorking[i].enabled = ListView_GetCheckState(remListHwnd, UINT(i)) != 0
|
||||
dlgSaved = true
|
||||
dlgClosed = true
|
||||
DestroyWindow(hwnd)
|
||||
of IdRemCancel:
|
||||
dlgClosed = true
|
||||
DestroyWindow(hwnd)
|
||||
else: discard
|
||||
return 0
|
||||
of WM_DESTROY:
|
||||
dlgClosed = true
|
||||
return 0
|
||||
else: discard
|
||||
return DefWindowProc(hwnd, msg, wParam, lParam)
|
||||
|
||||
proc showRemindersDialog(parent: HWND, inst: HINSTANCE, cfg: var Config) =
|
||||
## Dialogue de gestion des rappels santé. Les modifications ne sont appliquées
|
||||
## qu'à la validation (bouton OK).
|
||||
remWorking = cfg.healthReminders
|
||||
dlgClosed = false
|
||||
dlgSaved = false
|
||||
ensureCommonControls()
|
||||
|
||||
let w = 440'i32
|
||||
let h = 340'i32
|
||||
|
||||
var wc: WNDCLASSW
|
||||
wc.lpfnWndProc = cast[WNDPROC](remindersProc)
|
||||
wc.hInstance = inst
|
||||
wc.lpszClassName = L"TrayTempoRemindersClass"
|
||||
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
|
||||
discard RegisterClass(&wc)
|
||||
|
||||
let hwnd = CreateWindowExW(0, L"TrayTempoRemindersClass", S.settingsHealth,
|
||||
WS_CAPTION or WS_SYSMENU or WS_VISIBLE,
|
||||
0, 0, w, h, parent, 0, inst, nil)
|
||||
centerWindow(hwnd, w, h)
|
||||
|
||||
remListHwnd = makeCtrl("SysListView32", "", WS_CHILD or WS_VISIBLE or WS_TABSTOP or
|
||||
LVS_REPORT or LVS_SINGLESEL or LVS_SHOWSELALWAYS,
|
||||
12, 12, w - 120, h - 80, IdRemList, hwnd, inst)
|
||||
ListView_SetExtendedListViewStyle(remListHwnd, LVS_EX_CHECKBOXES or LVS_EX_FULLROWSELECT)
|
||||
|
||||
# Une colonne pleine largeur pour afficher le texte des rappels.
|
||||
var col: LVCOLUMN
|
||||
col.mask = LVCF_WIDTH
|
||||
col.cx = w - 140
|
||||
discard SendMessage(remListHwnd, LVM_INSERTCOLUMN, 0, cast[LPARAM](&col))
|
||||
|
||||
discard button(S.reminderAdd, w - 100, 12, 88, 28, IdRemAdd, hwnd, inst)
|
||||
discard button(S.reminderEdit, w - 100, 46, 88, 28, IdRemEdit, hwnd, inst)
|
||||
discard button(S.reminderRemove, w - 100, 80, 88, 28, IdRemRemove, hwnd, inst)
|
||||
|
||||
discard button(S.btnOk, w - 210, h - 50, 95, 28, IdRemOk, hwnd, inst)
|
||||
discard button(S.btnCancel, w - 105, h - 50, 95, 28, IdRemCancel, hwnd, inst)
|
||||
|
||||
refreshReminderList(remListHwnd)
|
||||
|
||||
var m: MSG
|
||||
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
|
||||
if not IsDialogMessage(hwnd, &m):
|
||||
TranslateMessage(&m)
|
||||
DispatchMessage(&m)
|
||||
|
||||
if dlgSaved:
|
||||
cfg.healthReminders = remWorking
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Panneau de réglages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc saveSettingsFromControls(hwnd: HWND, cfg: var Config) =
|
||||
cfg.workMin = getEditInt(hwnd, IdEditWork, cfg.workMin)
|
||||
cfg.shortBreakMin = getEditInt(hwnd, IdEditShortBreak, cfg.shortBreakMin)
|
||||
cfg.longBreakMin = getEditInt(hwnd, IdEditLongBreak, cfg.longBreakMin)
|
||||
cfg.cyclesBeforeLongBreak = getEditInt(hwnd, IdEditCycles, cfg.cyclesBeforeLongBreak)
|
||||
cfg.dailyGoal = getEditInt(hwnd, IdEditGoal, cfg.dailyGoal)
|
||||
cfg.soundEnabled = isChecked(hwnd, IdChkSound)
|
||||
cfg.doNotDisturb = isChecked(hwnd, IdChkDnd)
|
||||
cfg.autoStart = isChecked(hwnd, IdChkAutoStart)
|
||||
if isChecked(hwnd, IdRadioFr): cfg.lang = "fr"
|
||||
elif isChecked(hwnd, IdRadioEn): cfg.lang = "en"
|
||||
else: cfg.lang = "auto"
|
||||
|
||||
proc settingsProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
|
||||
case msg
|
||||
of WM_COMMAND:
|
||||
let id = int(wParam and 0xFFFF)
|
||||
case id
|
||||
of IdBtnSave:
|
||||
saveSettingsFromControls(hwnd, dlgCfg)
|
||||
dlgSaved = true
|
||||
dlgClosed = true
|
||||
DestroyWindow(hwnd)
|
||||
of IdBtnCancel:
|
||||
dlgClosed = true
|
||||
DestroyWindow(hwnd)
|
||||
of IdBtnReminders:
|
||||
showRemindersDialog(hwnd, GetModuleHandleW(nil), dlgCfg)
|
||||
else: discard
|
||||
return 0
|
||||
of WM_CLOSE:
|
||||
dlgClosed = true
|
||||
DestroyWindow(hwnd)
|
||||
return 0
|
||||
of WM_DESTROY:
|
||||
dlgClosed = true
|
||||
return 0
|
||||
else: discard
|
||||
return DefWindowProc(hwnd, msg, wParam, lParam)
|
||||
|
||||
proc buildSettingsDialog*(parent: HWND, cfg: Config): HWND =
|
||||
## Construit la fenêtre et les contrôles du panneau de réglages (sans lancer
|
||||
## la boucle modale). Retourne le HWND créé. Utilisé par `showSettingsDialog`
|
||||
## et par les tests d'intégration.
|
||||
dlgCfg = cfg
|
||||
dlgClosed = false
|
||||
dlgSaved = false
|
||||
|
||||
let inst = GetModuleHandleW(nil)
|
||||
let w = 420'i32
|
||||
let h = 430'i32
|
||||
|
||||
var wc: WNDCLASSW
|
||||
wc.lpfnWndProc = cast[WNDPROC](settingsProc)
|
||||
wc.hInstance = inst
|
||||
wc.lpszClassName = L"TrayTempoSettingsClass"
|
||||
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
|
||||
discard RegisterClass(&wc)
|
||||
|
||||
let hwnd = CreateWindowExW(0, L"TrayTempoSettingsClass", S.settingsTitle,
|
||||
WS_CAPTION or WS_SYSMENU or WS_VISIBLE,
|
||||
0, 0, w, h, parent, 0, inst, nil)
|
||||
centerWindow(hwnd, w, h)
|
||||
|
||||
# --- Durées ---
|
||||
var y = 12'i32
|
||||
discard label(S.settingsWork, 12, y + 3, 120, 18, 0, hwnd, inst)
|
||||
discard editNum(140, y, 60, 22, IdEditWork, hwnd, inst)
|
||||
discard label(S.settingsMinutes, 206, y + 3, 40, 18, 0, hwnd, inst)
|
||||
|
||||
y += 28
|
||||
discard label(S.settingsShortBreak, 12, y + 3, 120, 18, 0, hwnd, inst)
|
||||
discard editNum(140, y, 60, 22, IdEditShortBreak, hwnd, inst)
|
||||
discard label(S.settingsMinutes, 206, y + 3, 40, 18, 0, hwnd, inst)
|
||||
|
||||
y += 28
|
||||
discard label(S.settingsLongBreak, 12, y + 3, 120, 18, 0, hwnd, inst)
|
||||
discard editNum(140, y, 60, 22, IdEditLongBreak, hwnd, inst)
|
||||
discard label(S.settingsMinutes, 206, y + 3, 40, 18, 0, hwnd, inst)
|
||||
|
||||
y += 28
|
||||
discard label(S.settingsCycles, 12, y + 3, 180, 18, 0, hwnd, inst)
|
||||
discard editNum(200, y, 60, 22, IdEditCycles, hwnd, inst)
|
||||
|
||||
y += 34
|
||||
discard checkbox(S.settingsSound, 12, y, 240, 20, IdChkSound, hwnd, inst)
|
||||
y += 24
|
||||
discard checkbox(S.settingsDoNotDisturb, 12, y, 320, 20, IdChkDnd, hwnd, inst)
|
||||
y += 24
|
||||
discard checkbox(S.settingsAutoStart, 12, y, 240, 20, IdChkAutoStart, hwnd, inst)
|
||||
|
||||
y += 30
|
||||
discard label(S.settingsGoal, 12, y + 3, 140, 18, 0, hwnd, inst)
|
||||
discard editNum(160, y, 60, 22, IdEditGoal, hwnd, inst)
|
||||
discard label(S.settingsGoalHint, 12, y + 24, 380, 16, 0, hwnd, inst)
|
||||
|
||||
y += 46
|
||||
discard label(S.settingsLanguage, 12, y, 100, 18, 0, hwnd, inst)
|
||||
discard radio("Auto", 120, y, 70, 20, IdRadioAuto, hwnd, inst, true)
|
||||
discard radio("Français", 190, y, 90, 20, IdRadioFr, hwnd, inst, false)
|
||||
discard radio("English", 285, y, 90, 20, IdRadioEn, hwnd, inst, false)
|
||||
|
||||
y += 30
|
||||
discard button(S.settingsEditReminders, 12, y, 220, 28, IdBtnReminders, hwnd, inst)
|
||||
|
||||
# --- Boutons ---
|
||||
discard button(S.settingsSave, w - 210, h - 62, 100, 30, IdBtnSave, hwnd, inst)
|
||||
discard button(S.settingsCancel, w - 105, h - 62, 100, 30, IdBtnCancel, hwnd, inst)
|
||||
|
||||
# --- Pré-remplissage ---
|
||||
setEditInt(hwnd, IdEditWork, cfg.workMin)
|
||||
setEditInt(hwnd, IdEditShortBreak, cfg.shortBreakMin)
|
||||
setEditInt(hwnd, IdEditLongBreak, cfg.longBreakMin)
|
||||
setEditInt(hwnd, IdEditCycles, cfg.cyclesBeforeLongBreak)
|
||||
setEditInt(hwnd, IdEditGoal, cfg.dailyGoal)
|
||||
setChecked(hwnd, IdChkSound, cfg.soundEnabled)
|
||||
setChecked(hwnd, IdChkDnd, cfg.doNotDisturb)
|
||||
setChecked(hwnd, IdChkAutoStart, cfg.autoStart)
|
||||
case cfg.lang
|
||||
of "fr": setChecked(hwnd, IdRadioFr, true)
|
||||
of "en": setChecked(hwnd, IdRadioEn, true)
|
||||
else: setChecked(hwnd, IdRadioAuto, true)
|
||||
|
||||
discard SetFocus(GetDlgItem(hwnd, IdEditWork))
|
||||
result = hwnd
|
||||
|
||||
proc showSettingsDialog*(parent: HWND, cfg: var Config): bool =
|
||||
## Affiche le panneau de réglages. Retourne true si l'utilisateur a enregistré.
|
||||
let hwnd = buildSettingsDialog(parent, cfg)
|
||||
|
||||
# Boucle de messages locale (dialogue modal).
|
||||
var m: MSG
|
||||
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
|
||||
if not IsDialogMessage(hwnd, &m):
|
||||
TranslateMessage(&m)
|
||||
DispatchMessage(&m)
|
||||
|
||||
if dlgSaved:
|
||||
cfg = dlgCfg
|
||||
return dlgSaved
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assistant de premier lancement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const WizSteps = 5 # 0 welcome, 1 langue, 2 profil, 3 santé, 4 fin
|
||||
|
||||
proc wizRebuild(hwnd: HWND, inst: HINSTANCE) =
|
||||
# Efface tous les contrôles enfants existants.
|
||||
var child = GetWindow(hwnd, GW_CHILD)
|
||||
while child != 0:
|
||||
let next = GetWindow(child, GW_HWNDNEXT)
|
||||
DestroyWindow(child)
|
||||
child = next
|
||||
|
||||
case wizStep
|
||||
of 0:
|
||||
discard label(S.wizardWelcomeTitle, 16, 16, 460, 26, 0, hwnd, inst)
|
||||
discard label(S.wizardWelcomeBody, 16, 48, 460, 60, 0, hwnd, inst)
|
||||
of 1:
|
||||
discard label(S.wizardLangPrompt, 16, 16, 460, 22, 0, hwnd, inst)
|
||||
discard radio("Français", 30, 48, 200, 22, IdWizRadioBase, hwnd, inst, true)
|
||||
discard radio("English", 30, 76, 200, 22, int32(IdWizRadioBase + 1), hwnd, inst, false)
|
||||
# Pré-sélection selon la langue détectée.
|
||||
setChecked(hwnd, IdWizRadioBase, detectSystemLang() == langFr)
|
||||
setChecked(hwnd, int32(IdWizRadioBase + 1), detectSystemLang() != langFr)
|
||||
of 2:
|
||||
discard label(S.wizardProfilePrompt, 16, 16, 460, 22, 0, hwnd, inst)
|
||||
let profs = wizCfg.profiles
|
||||
for i, p in profs:
|
||||
let name = p.name & " (" & $p.workMin & "/" & $p.shortBreakMin & "/" &
|
||||
$p.longBreakMin & " min)"
|
||||
discard radio(name, 30, 48 + i * 26, 440, 22, int32(IdWizRadioBase + i), hwnd, inst, i == 0)
|
||||
# Pré-sélectionne le profil actif.
|
||||
for i, p in profs:
|
||||
if p.name == wizCfg.activeProfile:
|
||||
setChecked(hwnd, int32(IdWizRadioBase + i), true)
|
||||
of 3:
|
||||
discard label(S.wizardHealthPrompt, 16, 16, 460, 22, 0, hwnd, inst)
|
||||
for i, r in wizCfg.healthReminders:
|
||||
discard checkbox(r.text, 30, 48 + i * 26, 440, 22, int32(IdWizChkBase + i), hwnd, inst)
|
||||
setChecked(hwnd, int32(IdWizChkBase + i), r.enabled)
|
||||
of 4:
|
||||
discard label(S.wizardDoneTitle, 16, 16, 460, 26, 0, hwnd, inst)
|
||||
discard label(S.wizardDoneBody, 16, 48, 460, 80, 0, hwnd, inst)
|
||||
else: discard
|
||||
|
||||
# Boutons Bas / Suivant / Terminer.
|
||||
let backEnabled = wizStep > 0
|
||||
discard button(S.btnCancel, 20, 300, 90, 30, IdWizBack, hwnd, inst)
|
||||
if not backEnabled:
|
||||
discard EnableWindow(GetDlgItem(hwnd, IdWizBack), FALSE)
|
||||
let nextLabel = if wizStep == WizSteps - 1: S.settingsSave else: S.btnOk
|
||||
discard button(nextLabel, 400, 300, 90, 30, IdWizNext, hwnd, inst)
|
||||
|
||||
proc wizCollectStep(hwnd: HWND) =
|
||||
## Lit les choix de l'étape courante dans wizCfg.
|
||||
case wizStep
|
||||
of 1:
|
||||
if isChecked(hwnd, IdWizRadioBase): wizCfg.lang = "fr"
|
||||
elif isChecked(hwnd, int32(IdWizRadioBase + 1)): wizCfg.lang = "en"
|
||||
of 2:
|
||||
let profs = wizCfg.profiles
|
||||
for i in 0 ..< profs.len:
|
||||
if isChecked(hwnd, int32(IdWizRadioBase + i)):
|
||||
wizCfg.activeProfile = profs[i].name
|
||||
of 3:
|
||||
for i in 0 ..< wizCfg.healthReminders.len:
|
||||
wizCfg.healthReminders[i].enabled = isChecked(hwnd, int32(IdWizChkBase + i))
|
||||
else: discard
|
||||
|
||||
proc wizardProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
|
||||
case msg
|
||||
of WM_COMMAND:
|
||||
let id = int(wParam and 0xFFFF)
|
||||
case id
|
||||
of IdWizNext:
|
||||
wizCollectStep(hwnd)
|
||||
if wizStep < WizSteps - 1:
|
||||
inc wizStep
|
||||
wizRebuild(hwnd, GetModuleHandleW(nil))
|
||||
else:
|
||||
dlgSaved = true
|
||||
dlgClosed = true
|
||||
DestroyWindow(hwnd)
|
||||
of IdWizBack:
|
||||
if wizStep > 0:
|
||||
dec wizStep
|
||||
wizRebuild(hwnd, GetModuleHandleW(nil))
|
||||
else: discard
|
||||
return 0
|
||||
of WM_CLOSE:
|
||||
dlgClosed = true
|
||||
DestroyWindow(hwnd)
|
||||
return 0
|
||||
of WM_DESTROY:
|
||||
dlgClosed = true
|
||||
return 0
|
||||
else: discard
|
||||
return DefWindowProc(hwnd, msg, wParam, lParam)
|
||||
|
||||
proc showFirstRunWizard*(cfg: var Config): bool =
|
||||
## Assistant de premier lancement. Retourne true si l'utilisateur l'a terminé.
|
||||
wizCfg = cfg
|
||||
wizStep = 0
|
||||
dlgClosed = false
|
||||
dlgSaved = false
|
||||
|
||||
let inst = GetModuleHandleW(nil)
|
||||
let w = 520'i32
|
||||
let h = 360'i32
|
||||
|
||||
var wc: WNDCLASSW
|
||||
wc.lpfnWndProc = cast[WNDPROC](wizardProc)
|
||||
wc.hInstance = inst
|
||||
wc.lpszClassName = L"TrayTempoWizardClass"
|
||||
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
|
||||
discard RegisterClass(&wc)
|
||||
|
||||
let hwnd = CreateWindowExW(0, L"TrayTempoWizardClass", S.wizardWelcomeTitle,
|
||||
WS_CAPTION or WS_SYSMENU or WS_VISIBLE,
|
||||
0, 0, w, h, 0, 0, inst, nil)
|
||||
centerWindow(hwnd, w, h)
|
||||
wizRebuild(hwnd, inst)
|
||||
|
||||
var m: MSG
|
||||
while not dlgClosed and GetMessage(&m, 0, 0, 0) != 0:
|
||||
if not IsDialogMessage(hwnd, &m):
|
||||
TranslateMessage(&m)
|
||||
DispatchMessage(&m)
|
||||
|
||||
if dlgSaved:
|
||||
cfg = wizCfg
|
||||
return dlgSaved
|
||||
@@ -0,0 +1,76 @@
|
||||
## floating.nim
|
||||
## =============
|
||||
## Fenêtre flottante compacte : un petit panneau toujours au-dessus qui affiche
|
||||
## la phase et le temps restant en grand. Utile pour voir l'état du minuteur
|
||||
## d'un coup d'œil sans ouvrir le menu tray. Draggable à la souris.
|
||||
|
||||
import winim/lean
|
||||
|
||||
var
|
||||
fHwnd: HWND
|
||||
fLabel: HWND
|
||||
fFont: HFONT
|
||||
|
||||
proc floatingProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
|
||||
case msg
|
||||
of WM_LBUTTONDOWN:
|
||||
# Permet de déplacer la fenêtre en la glissant.
|
||||
discard SendMessage(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0)
|
||||
return 0
|
||||
of WM_DESTROY:
|
||||
fHwnd = 0
|
||||
return 0
|
||||
else:
|
||||
discard
|
||||
return DefWindowProc(hwnd, msg, wParam, lParam)
|
||||
|
||||
proc floatingVisible*(): bool =
|
||||
fHwnd != 0
|
||||
|
||||
proc updateFloating*(text: string) =
|
||||
## Met à jour le texte affiché (phase + temps restant).
|
||||
if fHwnd != 0 and fLabel != 0:
|
||||
discard SetWindowTextW(fLabel, text)
|
||||
|
||||
proc showFloating*() =
|
||||
## Affiche (ou crée) la fenêtre flottante en bas à droite de l'écran.
|
||||
if fHwnd != 0:
|
||||
discard ShowWindow(fHwnd, SW_SHOW)
|
||||
return
|
||||
|
||||
let inst = GetModuleHandleW(nil)
|
||||
|
||||
var wc: WNDCLASSW
|
||||
wc.lpfnWndProc = cast[WNDPROC](floatingProc)
|
||||
wc.hInstance = inst
|
||||
wc.lpszClassName = L"TrayTempoFloatingClass"
|
||||
wc.hbrBackground = cast[HBRUSH](COLOR_WINDOW + 1)
|
||||
discard RegisterClass(&wc)
|
||||
|
||||
let w = 240'i32
|
||||
let h = 56'i32
|
||||
let scrW = GetSystemMetrics(SM_CXSCREEN)
|
||||
let scrH = GetSystemMetrics(SM_CYSCREEN)
|
||||
|
||||
fHwnd = CreateWindowExW(WS_EX_TOPMOST or WS_EX_TOOLWINDOW,
|
||||
L"TrayTempoFloatingClass", L"TrayTempo",
|
||||
WS_POPUP or WS_BORDER or WS_VISIBLE,
|
||||
scrW - w - 24, scrH - h - 80, w, h,
|
||||
0, 0, inst, nil)
|
||||
|
||||
# Police large et lisible (accessibilité : texte agrandi).
|
||||
fFont = CreateFontW(24, 0, 0, 0, FW_BOLD, 0, 0, 0, DEFAULT_CHARSET,
|
||||
0, 0, CLEARTYPE_QUALITY, 0, L"Segoe UI")
|
||||
|
||||
fLabel = CreateWindowExW(0, L"STATIC", L"", WS_CHILD or WS_VISIBLE or SS_CENTER,
|
||||
0, 0, w, h, fHwnd, cast[HMENU](1), inst, nil)
|
||||
discard SendMessage(fLabel, WM_SETFONT, cast[WPARAM](fFont), 1)
|
||||
|
||||
proc hideFloating*() =
|
||||
if fHwnd != 0:
|
||||
discard DestroyWindow(fHwnd)
|
||||
fHwnd = 0
|
||||
fLabel = 0
|
||||
if fFont != 0:
|
||||
discard DeleteObject(fFont)
|
||||
fFont = 0
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
## i18n.nim
|
||||
## =========
|
||||
## Internationalisation : toutes les chaînes visibles par l'utilisateur sont
|
||||
## regroupées dans un objet `Strings`, décliné en français et en anglais.
|
||||
## Un module global `S` référence le bundle actif. Le langage est choisi au
|
||||
## démarrage (détection système ou préférence utilisateur dans config.json).
|
||||
##
|
||||
## Ce module importe `config` (types) et `timer` (pour la phase), mais il est
|
||||
## lui-même importé par les modules d'interface (tray, notifications, traytempo).
|
||||
|
||||
import winim/lean
|
||||
import config
|
||||
import timer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chaînes d'interface (bundle actif)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
type Strings* = object
|
||||
appName*: string
|
||||
|
||||
# Phases du minuteur.
|
||||
phaseIdle*: string
|
||||
phaseWorking*: string
|
||||
phaseShortBreak*: string
|
||||
phaseLongBreak*: string
|
||||
phasePaused*: string
|
||||
|
||||
# Tooltip de l'icône tray.
|
||||
tipIdle*: string
|
||||
tipPaused*: string
|
||||
|
||||
# Menu contextuel.
|
||||
menuStart*: string
|
||||
menuResume*: string
|
||||
menuPause*: string
|
||||
menuStop*: string
|
||||
menuSkipBreak*: string
|
||||
menuExtendBreak*: string
|
||||
menuProfiles*: string
|
||||
menuDoNotDisturb*: string
|
||||
menuAutoStart*: string
|
||||
menuFloatingWindow*: string
|
||||
menuSettings*: string
|
||||
menuStats*: string
|
||||
menuExportCsv*: string
|
||||
menuQuit*: string
|
||||
|
||||
# Notifications.
|
||||
notifWorkDone*: string
|
||||
notifBreakDone*: string
|
||||
notifBackToWork*: string
|
||||
notifGoalReached*: string
|
||||
notifUpdateTitle*: string
|
||||
notifUpdateBody*: string
|
||||
breakShort*: string
|
||||
breakLong*: string
|
||||
|
||||
# Boîte de dialogue statistiques.
|
||||
statsTitle*: string
|
||||
statsToday*: string
|
||||
statsWeek*: string
|
||||
statsStreak*: string
|
||||
statsGoal*: string
|
||||
statsDays*: string
|
||||
statsDayWord*: string
|
||||
statsTotal*: string
|
||||
statsNone*: string
|
||||
statsExportError*: string
|
||||
|
||||
# Assistant de premier lancement.
|
||||
wizardWelcomeTitle*: string
|
||||
wizardWelcomeBody*: string
|
||||
wizardLangTitle*: string
|
||||
wizardLangPrompt*: string
|
||||
wizardProfileTitle*: string
|
||||
wizardProfilePrompt*: string
|
||||
wizardHealthTitle*: string
|
||||
wizardHealthPrompt*: string
|
||||
wizardDoneTitle*: string
|
||||
wizardDoneBody*: string
|
||||
|
||||
# Panneau de réglages.
|
||||
settingsTitle*: string
|
||||
settingsDurations*: string
|
||||
settingsWork*: string
|
||||
settingsShortBreak*: string
|
||||
settingsLongBreak*: string
|
||||
settingsCycles*: string
|
||||
settingsCyclesHint*: string
|
||||
settingsGeneral*: string
|
||||
settingsSound*: string
|
||||
settingsDoNotDisturb*: string
|
||||
settingsAutoStart*: string
|
||||
settingsGoal*: string
|
||||
settingsGoalHint*: string
|
||||
settingsLanguage*: string
|
||||
settingsHealth*: string
|
||||
settingsHealthHint*: string
|
||||
settingsEditReminders*: string
|
||||
reminderAdd*: string
|
||||
reminderEdit*: string
|
||||
reminderRemove*: string
|
||||
reminderNewTitle*: string
|
||||
reminderEditTitle*: string
|
||||
reminderTextPrompt*: string
|
||||
settingsProfiles*: string
|
||||
settingsProfilesHint*: string
|
||||
settingsSave*: string
|
||||
settingsCancel*: string
|
||||
settingsSaved*: string
|
||||
settingsMinutes*: string
|
||||
|
||||
# Libellés génériques.
|
||||
btnOk*: string
|
||||
btnCancel*: string
|
||||
|
||||
# Nom de profil de repli.
|
||||
profileFallback*: string
|
||||
|
||||
# Rappels santé par défaut (affichés dans l'assistant).
|
||||
reminderStand*: string
|
||||
reminderWater*: string
|
||||
reminderPushups*: string
|
||||
reminder2020*: string
|
||||
reminderStretch*: string
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bundles français / anglais
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const fr* = Strings(
|
||||
appName: "TrayTempo",
|
||||
|
||||
phaseIdle: "Inactif",
|
||||
phaseWorking: "Travail",
|
||||
phaseShortBreak: "Pause courte",
|
||||
phaseLongBreak: "Pause longue",
|
||||
phasePaused: "En pause",
|
||||
|
||||
tipIdle: "TrayTempo - Inactif",
|
||||
tipPaused: "TrayTempo - En pause",
|
||||
|
||||
menuStart: "Démarrer",
|
||||
menuResume: "Reprendre",
|
||||
menuPause: "Mettre en pause",
|
||||
menuStop: "Arrêter",
|
||||
menuSkipBreak: "Passer la pause",
|
||||
menuExtendBreak: "Prolonger la pause (+5 min)",
|
||||
menuProfiles: "Profils",
|
||||
menuDoNotDisturb: "Ne pas déranger",
|
||||
menuAutoStart: "Démarrer avec Windows",
|
||||
menuFloatingWindow: "Fenêtre flottante",
|
||||
menuSettings: "Paramètres",
|
||||
menuStats: "Statistiques",
|
||||
menuExportCsv: "Exporter les statistiques (CSV)",
|
||||
menuQuit: "Quitter",
|
||||
|
||||
notifWorkDone: "Travail terminé",
|
||||
notifBreakDone: "Pause terminée",
|
||||
notifBackToWork: "C'est reparti pour le travail !",
|
||||
notifGoalReached: "Objectif du jour atteint ! 🎉",
|
||||
notifUpdateTitle: "Mise à jour disponible",
|
||||
notifUpdateBody: "La version $1 est disponible.",
|
||||
breakShort: "courte",
|
||||
breakLong: "longue",
|
||||
|
||||
statsTitle: "Statistiques TrayTempo",
|
||||
statsToday: "Pomodoros aujourd'hui",
|
||||
statsWeek: "Pomodoros (7 jours)",
|
||||
statsStreak: "Série",
|
||||
statsGoal: "Objectif du jour",
|
||||
statsDays: "jour(s)",
|
||||
statsDayWord: "jour",
|
||||
statsTotal: "Total cumulé",
|
||||
statsNone: "Aucun pomodoro complété pour l'instant.",
|
||||
statsExportError: "Impossible d'exporter les statistiques.",
|
||||
|
||||
wizardWelcomeTitle: "Bienvenue dans TrayTempo",
|
||||
wizardWelcomeBody: "Configurons TrayTempo en quelques étapes simples pour que " &
|
||||
"vous soyez productif tout en prenant soin de votre santé.",
|
||||
wizardLangTitle: "Langue / Language",
|
||||
wizardLangPrompt: "Choisissez votre langue :",
|
||||
wizardProfileTitle: "Profil",
|
||||
wizardProfilePrompt: "Choisissez votre rythme de travail :",
|
||||
wizardHealthTitle: "Rappels santé",
|
||||
wizardHealthPrompt: "Quels rappels souhaitez-vous pendant vos pauses ?",
|
||||
wizardDoneTitle: "C'est prêt !",
|
||||
wizardDoneBody: "TrayTempo vit maintenant dans la zone de notification. " &
|
||||
"Clic droit sur l'icône pour ouvrir le menu, ou Ctrl+Alt+P pour démarrer/mettre en pause.",
|
||||
|
||||
settingsTitle: "Paramètres TrayTempo",
|
||||
settingsDurations: "Durées",
|
||||
settingsWork: "Travail",
|
||||
settingsShortBreak: "Pause courte",
|
||||
settingsLongBreak: "Pause longue",
|
||||
settingsCycles: "Cycles avant pause longue",
|
||||
settingsCyclesHint: "Nombre de sessions avant une pause longue.",
|
||||
settingsGeneral: "Général",
|
||||
settingsSound: "Son en fin de période",
|
||||
settingsDoNotDisturb: "Ne pas déranger (suspend les rappels santé)",
|
||||
settingsAutoStart: "Démarrer avec Windows",
|
||||
settingsGoal: "Objectif journalier",
|
||||
settingsGoalHint: "0 = pas d'objectif.",
|
||||
settingsLanguage: "Langue",
|
||||
settingsHealth: "Rappels santé",
|
||||
settingsHealthHint: "Modifiez les rappels affichés pendant les pauses.",
|
||||
settingsEditReminders: "Modifier les rappels santé",
|
||||
reminderAdd: "Ajouter",
|
||||
reminderEdit: "Modifier",
|
||||
reminderRemove: "Supprimer",
|
||||
reminderNewTitle: "Nouveau rappel",
|
||||
reminderEditTitle: "Modifier le rappel",
|
||||
reminderTextPrompt: "Texte du rappel :",
|
||||
settingsProfiles: "Profils",
|
||||
settingsProfilesHint: "Modifiez vos profils dans le fichier de configuration.",
|
||||
settingsSave: "Enregistrer",
|
||||
settingsCancel: "Annuler",
|
||||
settingsSaved: "Paramètres enregistrés.",
|
||||
settingsMinutes: "min",
|
||||
|
||||
btnOk: "OK",
|
||||
btnCancel: "Annuler",
|
||||
|
||||
profileFallback: "Défaut",
|
||||
|
||||
reminderStand: "Lève-toi et marche 2 minutes",
|
||||
reminderWater: "Va boire un verre d'eau",
|
||||
reminderPushups: "Fais 10 push-ups",
|
||||
reminder2020: "Règle 20-20-20 : regarde à 20 pieds pendant 20 secondes",
|
||||
reminderStretch: "Étire ton cou et tes épaules",
|
||||
)
|
||||
|
||||
const en* = Strings(
|
||||
appName: "TrayTempo",
|
||||
|
||||
phaseIdle: "Idle",
|
||||
phaseWorking: "Working",
|
||||
phaseShortBreak: "Short break",
|
||||
phaseLongBreak: "Long break",
|
||||
phasePaused: "Paused",
|
||||
|
||||
tipIdle: "TrayTempo - Idle",
|
||||
tipPaused: "TrayTempo - Paused",
|
||||
|
||||
menuStart: "Start",
|
||||
menuResume: "Resume",
|
||||
menuPause: "Pause",
|
||||
menuStop: "Stop",
|
||||
menuSkipBreak: "Skip break",
|
||||
menuExtendBreak: "Extend break (+5 min)",
|
||||
menuProfiles: "Profiles",
|
||||
menuDoNotDisturb: "Do not disturb",
|
||||
menuAutoStart: "Start with Windows",
|
||||
menuFloatingWindow: "Floating window",
|
||||
menuSettings: "Settings",
|
||||
menuStats: "Statistics",
|
||||
menuExportCsv: "Export statistics (CSV)",
|
||||
menuQuit: "Quit",
|
||||
|
||||
notifWorkDone: "Work session complete",
|
||||
notifBreakDone: "Break over",
|
||||
notifBackToWork: "Back to work!",
|
||||
notifGoalReached: "Daily goal reached! 🎉",
|
||||
notifUpdateTitle: "Update available",
|
||||
notifUpdateBody: "Version $1 is available.",
|
||||
breakShort: "short",
|
||||
breakLong: "long",
|
||||
|
||||
statsTitle: "TrayTempo Statistics",
|
||||
statsToday: "Pomodoros today",
|
||||
statsWeek: "Pomodoros (7 days)",
|
||||
statsStreak: "Streak",
|
||||
statsGoal: "Daily goal",
|
||||
statsDays: "day(s)",
|
||||
statsDayWord: "day",
|
||||
statsTotal: "Total",
|
||||
statsNone: "No pomodoros completed yet.",
|
||||
statsExportError: "Could not export statistics.",
|
||||
|
||||
wizardWelcomeTitle: "Welcome to TrayTempo",
|
||||
wizardWelcomeBody: "Let's set up TrayTempo in a few simple steps so you can " &
|
||||
"be productive while taking care of your health.",
|
||||
wizardLangTitle: "Language",
|
||||
wizardLangPrompt: "Choose your language:",
|
||||
wizardProfileTitle: "Profile",
|
||||
wizardProfilePrompt: "Choose your work rhythm:",
|
||||
wizardHealthTitle: "Health reminders",
|
||||
wizardHealthPrompt: "Which reminders do you want during your breaks?",
|
||||
wizardDoneTitle: "You're ready!",
|
||||
wizardDoneBody: "TrayTempo now lives in the system tray. " &
|
||||
"Right-click the icon for the menu, or press Ctrl+Alt+P to start/pause.",
|
||||
|
||||
settingsTitle: "TrayTempo Settings",
|
||||
settingsDurations: "Durations",
|
||||
settingsWork: "Work",
|
||||
settingsShortBreak: "Short break",
|
||||
settingsLongBreak: "Long break",
|
||||
settingsCycles: "Cycles before long break",
|
||||
settingsCyclesHint: "Number of sessions before a long break.",
|
||||
settingsGeneral: "General",
|
||||
settingsSound: "Sound at end of period",
|
||||
settingsDoNotDisturb: "Do not disturb (suspend health reminders)",
|
||||
settingsAutoStart: "Start with Windows",
|
||||
settingsGoal: "Daily goal",
|
||||
settingsGoalHint: "0 = no goal.",
|
||||
settingsLanguage: "Language",
|
||||
settingsHealth: "Health reminders",
|
||||
settingsHealthHint: "Edit the reminders shown during breaks.",
|
||||
settingsEditReminders: "Edit health reminders",
|
||||
reminderAdd: "Add",
|
||||
reminderEdit: "Edit",
|
||||
reminderRemove: "Remove",
|
||||
reminderNewTitle: "New reminder",
|
||||
reminderEditTitle: "Edit reminder",
|
||||
reminderTextPrompt: "Reminder text:",
|
||||
settingsProfiles: "Profiles",
|
||||
settingsProfilesHint: "Edit your profiles in the configuration file.",
|
||||
settingsSave: "Save",
|
||||
settingsCancel: "Cancel",
|
||||
settingsSaved: "Settings saved.",
|
||||
settingsMinutes: "min",
|
||||
|
||||
btnOk: "OK",
|
||||
btnCancel: "Cancel",
|
||||
|
||||
profileFallback: "Default",
|
||||
|
||||
reminderStand: "Stand up and walk for 2 minutes",
|
||||
reminderWater: "Go drink a glass of water",
|
||||
reminderPushups: "Do 10 push-ups",
|
||||
reminder2020: "20-20-20 rule: look 20 feet away for 20 seconds",
|
||||
reminderStretch: "Stretch your neck and shoulders",
|
||||
)
|
||||
|
||||
# Bundle actif (réaffecté au démarrage selon la langue).
|
||||
var S* = fr
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Détection et sélection de la langue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc setLang*(l: Lang) =
|
||||
## Sélectionne le bundle actif.
|
||||
S = if l == langFr: fr else: en
|
||||
|
||||
proc detectSystemLang*(): Lang =
|
||||
## Retourne la langue système (français si la langue utilisateur est
|
||||
## française, anglais sinon).
|
||||
let lid = GetUserDefaultUILanguage()
|
||||
if (lid and 0x3FF) == 0x0C: langFr else: langEn
|
||||
|
||||
proc resolveLang*(cfg: Config): Lang =
|
||||
## Résout la langue effective à partir de la config ("auto" => système).
|
||||
case cfg.lang
|
||||
of "en": langEn
|
||||
of "fr": langFr
|
||||
else: detectSystemLang()
|
||||
|
||||
proc phaseLabel*(p: TimerPhase): string =
|
||||
## Libellé localisé d'une phase.
|
||||
case p
|
||||
of phIdle: S.phaseIdle
|
||||
of phWorking: S.phaseWorking
|
||||
of phShortBreak: S.phaseShortBreak
|
||||
of phLongBreak: S.phaseLongBreak
|
||||
of phPaused: S.phasePaused
|
||||
@@ -0,0 +1,57 @@
|
||||
## notifications.nim
|
||||
## =================
|
||||
## Notifications Windows (toast via NIF_INFO / ballon de notification) et
|
||||
## rappels santé rotatifs. Le son de fin de période est aussi géré ici.
|
||||
|
||||
import winim/lean
|
||||
import winim/inc/shellapi
|
||||
import winim/inc/mmsystem
|
||||
import config
|
||||
|
||||
# Identifiant de l'icône tray (doit correspondre à celui de tray.nim).
|
||||
const TrayIconId = 1
|
||||
|
||||
# État de la rotation des rappels : index du dernier rappel affiché.
|
||||
var lastReminderIndex = -1
|
||||
|
||||
proc showToast*(hwnd: HWND, title: string, message: string,
|
||||
infoFlags: int32 = int32(NIIF_INFO)) =
|
||||
## Affiche une notification (toast sur Windows 10+, ballon sinon) via
|
||||
## Shell_NotifyIcon avec NIF_INFO.
|
||||
var nid: NOTIFYICONDATA
|
||||
nid.cbSize = sizeof(NOTIFYICONDATA).DWORD
|
||||
nid.hWnd = hwnd
|
||||
nid.uID = TrayIconId
|
||||
nid.uFlags = NIF_INFO
|
||||
nid.dwInfoFlags = infoFlags or int32(NIIF_RESPECT_QUIET_TIME)
|
||||
# On tronque pour rester dans les tailles des buffers (63 / 255 caractères).
|
||||
let t = if title.len > 60: title[0 ..< 60] else: title
|
||||
let m = if message.len > 240: message[0 ..< 240] else: message
|
||||
nid.szInfoTitle <<< +$t
|
||||
nid.szInfo <<< +$m
|
||||
discard Shell_NotifyIcon(NIM_MODIFY, &nid)
|
||||
|
||||
proc playSound*(soundName = "SystemAsterisk"): bool =
|
||||
## Joue le son système à la fin d'une période (non bloquant). Retourne true
|
||||
## si le son a été correctement lancé (code de retour de l'API).
|
||||
PlaySound(soundName, 0, SND_ALIAS or SND_ASYNC) != 0
|
||||
|
||||
proc nextHealthReminder*(cfg: Config): string =
|
||||
## Choisit le prochain rappel santé activé sans répéter le précédent.
|
||||
## Retourne "" si aucun rappel n'est activé.
|
||||
var enabledIdx: seq[int] = @[]
|
||||
for i, r in cfg.healthReminders:
|
||||
if r.enabled and r.text.len > 0:
|
||||
enabledIdx.add i
|
||||
|
||||
if enabledIdx.len == 0:
|
||||
return ""
|
||||
|
||||
var chosen = enabledIdx[0]
|
||||
if enabledIdx.len > 1:
|
||||
for idx in enabledIdx:
|
||||
if idx != lastReminderIndex:
|
||||
chosen = idx
|
||||
break
|
||||
lastReminderIndex = chosen
|
||||
return cfg.healthReminders[chosen].text
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
## stats.nim
|
||||
## =========
|
||||
## Statistiques simples : compteur de pomodoros complétés (jour / semaine)
|
||||
## et streak de jours consécutifs d'utilisation. Sauvegarde locale en JSON.
|
||||
|
||||
import std/[json, os, tables, times, algorithm, strutils]
|
||||
import config
|
||||
|
||||
type
|
||||
Stats* = object
|
||||
daily*: Table[string, int] ## date "yyyy-MM-dd" -> nombre de pomodoros.
|
||||
|
||||
proc todayStr*(): string =
|
||||
now().format("yyyy-MM-dd")
|
||||
|
||||
proc initStats*(): Stats =
|
||||
Stats(daily: initTable[string, int]())
|
||||
|
||||
proc loadStats*(): Stats =
|
||||
## Charge les stats depuis le disque (jamais de crash si corrompu).
|
||||
result = initStats()
|
||||
try:
|
||||
let path = statsPath()
|
||||
if not fileExists(path):
|
||||
return
|
||||
let n = parseFile(path)
|
||||
let d = n.getOrDefault("daily")
|
||||
if d.kind == JObject:
|
||||
for k, v in d.fields:
|
||||
result.daily[k] = v.getInt(0)
|
||||
except:
|
||||
discard
|
||||
|
||||
proc saveStats*(st: Stats) =
|
||||
try:
|
||||
ensureConfigDir()
|
||||
var n = newJObject()
|
||||
var daily = newJObject()
|
||||
for k, v in st.daily:
|
||||
daily[k] = %v
|
||||
n["daily"] = daily
|
||||
writeFile(statsPath(), n.pretty)
|
||||
except:
|
||||
discard
|
||||
|
||||
proc recordPomodoro*(st: var Stats) =
|
||||
## Incrémente le compteur du jour courant puis sauvegarde.
|
||||
let today = todayStr()
|
||||
st.daily[today] = st.daily.getOrDefault(today, 0) + 1
|
||||
saveStats(st)
|
||||
|
||||
proc completedToday*(st: Stats): int =
|
||||
st.daily.getOrDefault(todayStr(), 0)
|
||||
|
||||
proc goalReached*(st: Stats, goal: int): bool =
|
||||
## Vrai si l'objectif journalier (>0) vient d'être exactement atteint.
|
||||
goal > 0 and st.completedToday() == goal
|
||||
|
||||
proc completedThisWeek*(st: Stats): int =
|
||||
## Somme des pomodoros des 7 derniers jours (incluant aujourd'hui).
|
||||
var d = now()
|
||||
for i in 0 ..< 7:
|
||||
result += st.daily.getOrDefault(d.format("yyyy-MM-dd"), 0)
|
||||
d = d - 1.days
|
||||
|
||||
proc streak*(st: Stats): int =
|
||||
## Nombre de jours consécutifs (jusqu'à aujourd'hui ou hier) avec au moins
|
||||
## un pomodoro complété. Le jour courant ne casse pas la série tant qu'il
|
||||
## n'est pas terminé.
|
||||
var d = now()
|
||||
if not st.daily.hasKey(todayStr()):
|
||||
d = d - 1.days
|
||||
while st.daily.getOrDefault(d.format("yyyy-MM-dd"), 0) > 0:
|
||||
inc result
|
||||
d = d - 1.days
|
||||
|
||||
proc totalPomodoros*(st: Stats): int =
|
||||
## Somme de tous les pomodoros enregistrés (historique complet).
|
||||
for v in st.daily.values:
|
||||
result += v
|
||||
|
||||
proc lastNDays*(st: Stats, n: int): seq[(string, int)] =
|
||||
## Retourne les n derniers jours (du plus ancien au plus récent) sous forme
|
||||
## de paires (date, nombre), en incluant les jours sans activité.
|
||||
var d = now() - (n - 1).days
|
||||
for i in 0 ..< n:
|
||||
let key = d.format("yyyy-MM-dd")
|
||||
result.add (key, st.daily.getOrDefault(key, 0))
|
||||
d = d + 1.days
|
||||
|
||||
proc exportCsv*(st: Stats, path: string): bool =
|
||||
## Exporte l'historique au format CSV (date,count). Retourne true si réussi.
|
||||
try:
|
||||
var lines = @["date,count"]
|
||||
# Trie les dates par ordre croissant.
|
||||
var keys: seq[string] = @[]
|
||||
for k in st.daily.keys:
|
||||
keys.add k
|
||||
keys.sort()
|
||||
for k in keys:
|
||||
lines.add k & "," & $st.daily[k]
|
||||
writeFile(path, lines.join("\n"))
|
||||
result = true
|
||||
except:
|
||||
result = false
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
## timer.nim
|
||||
## =========
|
||||
## Machine à états du minuteur Pomodoro. Aucune dépendance Win32 : le module
|
||||
## ne fait que compter les secondes et décider des transitions de phase.
|
||||
##
|
||||
## Phases : idle, working, shortBreak, longBreak, paused.
|
||||
|
||||
import config
|
||||
|
||||
type
|
||||
TimerPhase* = enum
|
||||
phIdle, phWorking, phShortBreak, phLongBreak, phPaused
|
||||
|
||||
## Événement renvoyé par tick() à la fin d'une période.
|
||||
TickEvent* = enum
|
||||
evNone ## Rien à signaler.
|
||||
evWorkCompleted ## Une période de travail vient de se terminer.
|
||||
evBreakCompleted ## Une pause vient de se terminer.
|
||||
|
||||
PomodoroTimer* = object
|
||||
phase*: TimerPhase
|
||||
prevPhase*: TimerPhase ## Phase avant mise en pause (pour reprendre).
|
||||
remainingSecs*: int
|
||||
cycleCount*: int ## Pomodoros complétés dans le cycle courant.
|
||||
|
||||
proc formatTime*(secs: int): string =
|
||||
## Formate une durée en secondes sous la forme M:SS ou H:MM:SS.
|
||||
let m = secs div 60
|
||||
let s = secs mod 60
|
||||
if m >= 60:
|
||||
let h = m div 60
|
||||
let mm = m mod 60
|
||||
result = $h & ":" & (if mm < 10: "0" else: "") & $mm &
|
||||
":" & (if s < 10: "0" else: "") & $s
|
||||
else:
|
||||
result = $m & ":" & (if s < 10: "0" else: "") & $s
|
||||
|
||||
proc initTimer*(): PomodoroTimer =
|
||||
PomodoroTimer(phase: phIdle, prevPhase: phIdle, remainingSecs: 0, cycleCount: 0)
|
||||
|
||||
proc startWork*(t: var PomodoroTimer, p: Profile) =
|
||||
t.phase = phWorking
|
||||
t.prevPhase = phWorking
|
||||
t.remainingSecs = p.workMin * 60
|
||||
|
||||
proc startBreak*(t: var PomodoroTimer, p: Profile, longBreak: bool) =
|
||||
if longBreak:
|
||||
t.phase = phLongBreak
|
||||
t.remainingSecs = p.longBreakMin * 60
|
||||
else:
|
||||
t.phase = phShortBreak
|
||||
t.remainingSecs = p.shortBreakMin * 60
|
||||
t.prevPhase = t.phase
|
||||
|
||||
proc pause*(t: var PomodoroTimer) =
|
||||
if t.phase in {phWorking, phShortBreak, phLongBreak}:
|
||||
t.prevPhase = t.phase
|
||||
t.phase = phPaused
|
||||
|
||||
proc resume*(t: var PomodoroTimer) =
|
||||
if t.phase == phPaused:
|
||||
t.phase = t.prevPhase
|
||||
|
||||
proc stop*(t: var PomodoroTimer) =
|
||||
t.phase = phIdle
|
||||
t.remainingSecs = 0
|
||||
t.prevPhase = phIdle
|
||||
# On remet le cycle à zéro : après un arrêt, le prochain démarrage repart
|
||||
# d'un cycle complet (sinon la pause longue arrivait au mauvais moment).
|
||||
t.cycleCount = 0
|
||||
|
||||
proc isBreak*(t: PomodoroTimer): bool =
|
||||
## Vrai si le minuteur est dans une pause (courte ou longue).
|
||||
t.phase in {phShortBreak, phLongBreak}
|
||||
|
||||
proc skipBreak*(t: var PomodoroTimer, p: Profile) =
|
||||
## Saute la pause courante et repart immédiatement en travail.
|
||||
if t.isBreak():
|
||||
t.startWork(p)
|
||||
|
||||
proc extendBreak*(t: var PomodoroTimer, minutes: int) =
|
||||
## Prolonge la pause courante de `minutes` minutes.
|
||||
if t.isBreak() and minutes > 0:
|
||||
t.remainingSecs += minutes * 60
|
||||
|
||||
proc isRunning*(t: PomodoroTimer): bool =
|
||||
t.phase in {phWorking, phShortBreak, phLongBreak}
|
||||
|
||||
proc tick*(t: var PomodoroTimer, p: Profile): TickEvent =
|
||||
## Décrémente le temps restant d'une seconde. Retourne un événement quand
|
||||
## une période se termine et enchaîne automatiquement sur la suivante.
|
||||
if t.phase notin {phWorking, phShortBreak, phLongBreak}:
|
||||
return evNone
|
||||
if t.remainingSecs > 0:
|
||||
dec t.remainingSecs
|
||||
if t.remainingSecs > 0:
|
||||
return evNone
|
||||
|
||||
case t.phase
|
||||
of phWorking:
|
||||
inc t.cycleCount
|
||||
let longBreak = (p.cycles > 0) and (t.cycleCount mod p.cycles == 0)
|
||||
t.startBreak(p, longBreak)
|
||||
return evWorkCompleted
|
||||
of phShortBreak, phLongBreak:
|
||||
t.startWork(p)
|
||||
return evBreakCompleted
|
||||
else:
|
||||
return evNone
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
## tray.nim
|
||||
## ========
|
||||
## Gestion de l'icône dans la zone de notification (system tray) :
|
||||
## ajout / mise à jour / suppression de l'icône, icône colorée générée
|
||||
## dynamiquement selon l'état, et menu contextuel (clic droit).
|
||||
|
||||
import winim/lean
|
||||
import winim/inc/shellapi
|
||||
import config
|
||||
import timer
|
||||
import i18n
|
||||
|
||||
const
|
||||
TrayIconId* = 1
|
||||
WmTrayIcon* = WM_APP + 1 # message envoyé lors d'un clic sur l'icône
|
||||
|
||||
# Identifiants des commandes de menu (reçus via WM_COMMAND).
|
||||
IdStartPause* = 1001
|
||||
IdStop* = 1002
|
||||
IdSettings* = 1003
|
||||
IdStats* = 1004
|
||||
IdQuit* = 1005
|
||||
IdExportCsv* = 1006
|
||||
IdAutoStart* = 1500
|
||||
IdDoNotDisturb* = 1501
|
||||
IdSkipBreak* = 1502
|
||||
IdExtendBreak* = 1503
|
||||
IdFloatingWindow* = 1504
|
||||
IdProfileBase* = 2000 # + index dans cfg.profiles
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icône dynamique (un disque coloré généré à la volée via GDI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc createStateIcon(r, g, b: int): HICON =
|
||||
const S = 32'i32
|
||||
let screenDC = GetDC(0)
|
||||
|
||||
# --- Bitmap couleur (fond noir = transparent, disque de couleur) ---
|
||||
let colorDC = CreateCompatibleDC(screenDC)
|
||||
let colorBmp = CreateCompatibleBitmap(screenDC, S, S)
|
||||
let oldColor = SelectObject(colorDC, colorBmp)
|
||||
|
||||
var rc = RECT(left: 0, top: 0, right: S, bottom: S)
|
||||
var brush = CreateSolidBrush(RGB(0, 0, 0))
|
||||
FillRect(colorDC, &rc, brush)
|
||||
DeleteObject(brush)
|
||||
|
||||
brush = CreateSolidBrush(RGB(r, g, b))
|
||||
var oldBrush = SelectObject(colorDC, brush)
|
||||
Ellipse(colorDC, 3'i32, 3'i32, S - 3'i32, S - 3'i32)
|
||||
SelectObject(colorDC, oldBrush)
|
||||
DeleteObject(brush)
|
||||
SelectObject(colorDC, oldColor)
|
||||
|
||||
# --- Masque monochrome (disque blanc opaque, coins transparents) ---
|
||||
let maskDC = CreateCompatibleDC(screenDC)
|
||||
let maskBmp = CreateBitmap(S, S, 1, 1, nil)
|
||||
let oldMask = SelectObject(maskDC, maskBmp)
|
||||
|
||||
brush = CreateSolidBrush(RGB(0, 0, 0))
|
||||
FillRect(maskDC, &rc, brush)
|
||||
DeleteObject(brush)
|
||||
|
||||
brush = CreateSolidBrush(RGB(255, 255, 255))
|
||||
oldBrush = SelectObject(maskDC, brush)
|
||||
Ellipse(maskDC, 3'i32, 3'i32, S - 3'i32, S - 3'i32)
|
||||
SelectObject(maskDC, oldBrush)
|
||||
DeleteObject(brush)
|
||||
SelectObject(maskDC, oldMask)
|
||||
|
||||
# --- Assemblage de l'icône ---
|
||||
var ii: ICONINFO
|
||||
ii.fIcon = TRUE
|
||||
ii.hbmColor = colorBmp
|
||||
ii.hbmMask = maskBmp
|
||||
result = CreateIconIndirect(&ii)
|
||||
|
||||
DeleteObject(colorBmp)
|
||||
DeleteObject(maskBmp)
|
||||
DeleteDC(colorDC)
|
||||
DeleteDC(maskDC)
|
||||
ReleaseDC(0, screenDC)
|
||||
|
||||
# Cache des icônes (une par phase) pour éviter de les recréer à chaque tick.
|
||||
var cachedIcons: array[TimerPhase, HICON]
|
||||
var iconsReady = false
|
||||
|
||||
proc phaseIcon*(phase: TimerPhase): HICON =
|
||||
if not iconsReady:
|
||||
cachedIcons[phIdle] = createStateIcon(128, 128, 128) # gris
|
||||
cachedIcons[phWorking] = createStateIcon(214, 69, 65) # rouge tomate
|
||||
cachedIcons[phShortBreak] = createStateIcon(80, 180, 90) # vert
|
||||
cachedIcons[phLongBreak] = createStateIcon(70, 130, 220) # bleu
|
||||
cachedIcons[phPaused] = createStateIcon(240, 180, 50) # ambre
|
||||
iconsReady = true
|
||||
result = cachedIcons[phase]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Icône dans le tray
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc addTrayIcon*(hwnd: HWND, icon: HICON, tip: string): bool =
|
||||
var nid: NOTIFYICONDATA
|
||||
nid.cbSize = sizeof(NOTIFYICONDATA).DWORD
|
||||
nid.hWnd = hwnd
|
||||
nid.uID = TrayIconId
|
||||
nid.uFlags = NIF_MESSAGE or NIF_ICON or NIF_TIP
|
||||
nid.uCallbackMessage = WmTrayIcon
|
||||
nid.hIcon = icon
|
||||
nid.szTip <<< +$tip
|
||||
result = Shell_NotifyIcon(NIM_ADD, &nid) != 0
|
||||
|
||||
proc updateTrayIcon*(hwnd: HWND, icon: HICON, tip: string) =
|
||||
var nid: NOTIFYICONDATA
|
||||
nid.cbSize = sizeof(NOTIFYICONDATA).DWORD
|
||||
nid.hWnd = hwnd
|
||||
nid.uID = TrayIconId
|
||||
nid.uFlags = NIF_ICON or NIF_TIP
|
||||
nid.hIcon = icon
|
||||
nid.szTip <<< +$tip
|
||||
discard Shell_NotifyIcon(NIM_MODIFY, &nid)
|
||||
|
||||
proc removeTrayIcon*(hwnd: HWND) =
|
||||
var nid: NOTIFYICONDATA
|
||||
nid.cbSize = sizeof(NOTIFYICONDATA).DWORD
|
||||
nid.hWnd = hwnd
|
||||
nid.uID = TrayIconId
|
||||
discard Shell_NotifyIcon(NIM_DELETE, &nid)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Menu contextuel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc tooltipText*(cfg: Config, t: PomodoroTimer): string =
|
||||
case t.phase
|
||||
of phIdle: S.tipIdle
|
||||
of phPaused: S.tipPaused & " (" & formatTime(t.remainingSecs) & ")"
|
||||
else: S.appName & " - " & phaseLabel(t.phase) & ": " & formatTime(t.remainingSecs)
|
||||
|
||||
# Constantes de flags de menu typées `UINT` (int32 dans winim) pour éviter les
|
||||
# conversions implicites int -> int32 (source d'erreurs de compilation).
|
||||
const
|
||||
mfStr = 0x00000000'i32
|
||||
mfGray = 0x00000001'i32
|
||||
mfChk = 0x00000008'i32
|
||||
mfSep = 0x00000800'i32
|
||||
mfPop = 0x00000010'i32
|
||||
|
||||
proc showTrayMenu*(hwnd: HWND, cfg: Config, t: PomodoroTimer) =
|
||||
## Construit et affiche le menu contextuel. Les commandes sont ensuite
|
||||
## reçues par la fenêtre via WM_COMMAND.
|
||||
let menu = CreatePopupMenu()
|
||||
|
||||
# Démarrer / Reprendre / Pause selon l'état.
|
||||
let toggleLabel =
|
||||
case t.phase
|
||||
of phIdle: S.menuStart
|
||||
of phPaused: S.menuResume
|
||||
else: S.menuPause
|
||||
AppendMenu(menu, mfStr, int64(IdStartPause), toggleLabel)
|
||||
|
||||
# Arrêter (désactivé si rien ne tourne).
|
||||
let stopFlags = if t.phase == phIdle: mfStr or mfGray else: mfStr
|
||||
AppendMenu(menu, stopFlags, int64(IdStop), S.menuStop)
|
||||
|
||||
# Sauter / prolonger la pause (uniquement pendant une pause).
|
||||
if t.isBreak():
|
||||
AppendMenu(menu, mfStr, int64(IdSkipBreak), S.menuSkipBreak)
|
||||
AppendMenu(menu, mfStr, int64(IdExtendBreak), S.menuExtendBreak)
|
||||
|
||||
AppendMenu(menu, mfSep, 0, nil)
|
||||
|
||||
# Sous-menu profils.
|
||||
let profilesMenu = CreatePopupMenu()
|
||||
if cfg.profiles.len > 0:
|
||||
for i, p in cfg.profiles:
|
||||
var flags = mfStr
|
||||
if p.name == cfg.activeProfile:
|
||||
flags = flags or mfChk
|
||||
AppendMenu(profilesMenu, flags, int64(IdProfileBase + i), p.name)
|
||||
AppendMenu(menu, mfPop, cast[UINT_PTR](profilesMenu), S.menuProfiles)
|
||||
|
||||
AppendMenu(menu, mfSep, 0, nil)
|
||||
|
||||
# Options pratiques (cochées si actives).
|
||||
AppendMenu(menu, if cfg.doNotDisturb: mfStr or mfChk else: mfStr,
|
||||
int64(IdDoNotDisturb), S.menuDoNotDisturb)
|
||||
AppendMenu(menu, if cfg.autoStart: mfStr or mfChk else: mfStr,
|
||||
int64(IdAutoStart), S.menuAutoStart)
|
||||
AppendMenu(menu, if cfg.floatingWindow: mfStr or mfChk else: mfStr,
|
||||
int64(IdFloatingWindow), S.menuFloatingWindow)
|
||||
|
||||
AppendMenu(menu, mfSep, 0, nil)
|
||||
AppendMenu(menu, mfStr, int64(IdSettings), S.menuSettings)
|
||||
AppendMenu(menu, mfStr, int64(IdStats), S.menuStats)
|
||||
AppendMenu(menu, mfStr, int64(IdExportCsv), S.menuExportCsv)
|
||||
AppendMenu(menu, mfSep, 0, nil)
|
||||
AppendMenu(menu, mfStr, int64(IdQuit), S.menuQuit)
|
||||
|
||||
var pt: POINT
|
||||
GetCursorPos(&pt)
|
||||
SetForegroundWindow(hwnd) # requis pour que le menu se referme proprement
|
||||
discard TrackPopupMenu(menu, TPM_RIGHTBUTTON or TPM_BOTTOMALIGN or TPM_LEFTALIGN,
|
||||
pt.x, pt.y, 0, hwnd, nil)
|
||||
# Le sous-menu profils est détruit avec le menu parent par DestroyMenu.
|
||||
DestroyMenu(menu)
|
||||
PostMessage(hwnd, WM_NULL, 0, 0)
|
||||
@@ -0,0 +1,297 @@
|
||||
## traytempo.nim
|
||||
## =============
|
||||
## Point d'entrée de l'application. Crée une fenêtre cachée, enregistre
|
||||
## l'icône tray, le minuteur (WM_TIMER) et le raccourci global, puis exécute
|
||||
## la boucle de messages Windows.
|
||||
##
|
||||
## Compilation :
|
||||
## nim c -d:release --app:gui src/traytempo.nim
|
||||
|
||||
import std/os
|
||||
import std/typedthreads
|
||||
import std/strutils
|
||||
import winim/lean
|
||||
import winim/inc/shellapi
|
||||
|
||||
import config
|
||||
import timer
|
||||
import tray
|
||||
import notifications
|
||||
import stats
|
||||
import i18n
|
||||
import dialogs
|
||||
import updater
|
||||
import floating
|
||||
|
||||
const
|
||||
TimerId = 1'i64
|
||||
HotkeyId = 1'i32
|
||||
SingleInstanceMutexName = "TrayTempo_SingleInstance_Mutex"
|
||||
WmUpdateAvailable = WM_APP + 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# État global de l'application
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var
|
||||
gCfg: Config
|
||||
gTimer: PomodoroTimer
|
||||
gStats: Stats
|
||||
gHwnd: HWND
|
||||
gUpdateLatest: array[32, char] ## Version distante (buffer non-GC, partagé
|
||||
## entre le thread de mise à jour et la boucle
|
||||
## de messages).
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Actions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc floatingText(): string =
|
||||
## Texte affiché dans la fenêtre flottante (phase + temps restant).
|
||||
case gTimer.phase
|
||||
of phIdle: S.phaseIdle
|
||||
of phPaused: S.phasePaused & " " & formatTime(gTimer.remainingSecs)
|
||||
else: phaseLabel(gTimer.phase) & " " & formatTime(gTimer.remainingSecs)
|
||||
|
||||
proc refreshTray() =
|
||||
## Met à jour l'icône et le tooltip selon l'état courant.
|
||||
updateTrayIcon(gHwnd, phaseIcon(gTimer.phase), tooltipText(gCfg, gTimer))
|
||||
if floatingVisible():
|
||||
updateFloating(floatingText())
|
||||
|
||||
proc updateCheckThread() {.thread.} =
|
||||
## Vérifie les mises à jour en arrière-plan et prévient la fenêtre principale.
|
||||
let latest = fetchLatestVersion()
|
||||
if latest.len > 0 and isNewer(AppVersion, latest):
|
||||
copyMem(addr gUpdateLatest[0], latest.cstring, min(latest.len + 1, 32))
|
||||
discard PostMessage(gHwnd, WmUpdateAvailable, 0, 0)
|
||||
|
||||
proc applyHotkey() =
|
||||
## (Ré)enregistre le raccourci global selon la configuration.
|
||||
discard UnregisterHotKey(gHwnd, HotkeyId)
|
||||
if gCfg.hotkeyVk != 0:
|
||||
discard RegisterHotKey(gHwnd, HotkeyId, UINT(gCfg.hotkeyModifiers),
|
||||
UINT(gCfg.hotkeyVk))
|
||||
|
||||
proc toggleStartPause() =
|
||||
case gTimer.phase
|
||||
of phIdle: gTimer.startWork(getActiveProfile(gCfg))
|
||||
of phPaused: gTimer.resume()
|
||||
else: gTimer.pause()
|
||||
refreshTray()
|
||||
|
||||
proc stopTimer() =
|
||||
gTimer.stop()
|
||||
refreshTray()
|
||||
|
||||
proc quitApp() =
|
||||
saveConfig(gCfg)
|
||||
hideFloating()
|
||||
removeTrayIcon(gHwnd)
|
||||
UnregisterHotKey(gHwnd, HotkeyId)
|
||||
DestroyWindow(gHwnd)
|
||||
|
||||
proc openSettings() =
|
||||
## Ouvre le panneau de réglages graphique.
|
||||
if showSettingsDialog(gHwnd, gCfg):
|
||||
saveConfig(gCfg)
|
||||
setLang(resolveLang(gCfg))
|
||||
discard setAutostart(gCfg.autoStart)
|
||||
applyHotkey()
|
||||
refreshTray()
|
||||
showToast(gHwnd, S.appName, S.settingsSaved)
|
||||
|
||||
proc showStatsDialog() =
|
||||
dialogs.showStatsDialog(gHwnd, gCfg, gStats)
|
||||
|
||||
proc exportStatsCsv() =
|
||||
## Exporte l'historique en CSV dans le répertoire de configuration.
|
||||
let path = configDir() / "stats_export.csv"
|
||||
if gStats.exportCsv(path):
|
||||
discard ShellExecuteW(0, "open", path, nil, nil, SW_SHOWNORMAL)
|
||||
else:
|
||||
discard MessageBox(gHwnd, S.statsExportError, S.appName, MB_ICONERROR)
|
||||
|
||||
proc onWorkCompleted() =
|
||||
## Fin d'une période de travail : comptabilise, son, notification et
|
||||
## rappel santé (si autorisé).
|
||||
gStats.recordPomodoro()
|
||||
if gCfg.soundEnabled:
|
||||
discard playSound(gCfg.soundName)
|
||||
var msg = S.notifWorkDone & " — " &
|
||||
(if gTimer.phase == phLongBreak: S.breakLong else: S.breakShort) &
|
||||
" (" & formatTime(gTimer.remainingSecs) & ")"
|
||||
if not gCfg.doNotDisturb:
|
||||
let reminder = nextHealthReminder(gCfg)
|
||||
if reminder.len > 0:
|
||||
msg = msg & "\n" & reminder
|
||||
showToast(gHwnd, S.notifWorkDone, msg)
|
||||
|
||||
# Objectif journalier atteint (notification unique au franchissement).
|
||||
if gStats.goalReached(gCfg.dailyGoal):
|
||||
showToast(gHwnd, S.appName, S.notifGoalReached)
|
||||
|
||||
proc onBreakCompleted() =
|
||||
if gCfg.soundEnabled:
|
||||
discard playSound(gCfg.soundName)
|
||||
showToast(gHwnd, S.notifBreakDone, S.notifBackToWork)
|
||||
|
||||
proc onTick() =
|
||||
## Appelé chaque seconde (WM_TIMER).
|
||||
case gTimer.tick(getActiveProfile(gCfg))
|
||||
of evWorkCompleted: onWorkCompleted()
|
||||
of evBreakCompleted: onBreakCompleted()
|
||||
of evNone: discard
|
||||
refreshTray()
|
||||
|
||||
proc skipBreak() =
|
||||
gTimer.skipBreak(getActiveProfile(gCfg))
|
||||
refreshTray()
|
||||
|
||||
proc extendBreak() =
|
||||
gTimer.extendBreak(5)
|
||||
refreshTray()
|
||||
|
||||
proc handleCommand(cmd: int) =
|
||||
case cmd
|
||||
of IdStartPause: toggleStartPause()
|
||||
of IdStop: stopTimer()
|
||||
of IdSkipBreak: skipBreak()
|
||||
of IdExtendBreak: extendBreak()
|
||||
of IdSettings: openSettings()
|
||||
of IdStats: showStatsDialog()
|
||||
of IdExportCsv: exportStatsCsv()
|
||||
of IdAutoStart:
|
||||
gCfg.autoStart = not gCfg.autoStart
|
||||
discard setAutostart(gCfg.autoStart)
|
||||
saveConfig(gCfg)
|
||||
of IdDoNotDisturb:
|
||||
gCfg.doNotDisturb = not gCfg.doNotDisturb
|
||||
saveConfig(gCfg)
|
||||
of IdFloatingWindow:
|
||||
gCfg.floatingWindow = not gCfg.floatingWindow
|
||||
if gCfg.floatingWindow:
|
||||
showFloating()
|
||||
updateFloating(floatingText())
|
||||
else:
|
||||
hideFloating()
|
||||
saveConfig(gCfg)
|
||||
of IdQuit: quitApp()
|
||||
else:
|
||||
# Sélection d'un profil (le changement s'applique à la prochaine période).
|
||||
let idx = cmd - IdProfileBase
|
||||
if idx >= 0 and idx < gCfg.profiles.len:
|
||||
gCfg.activeProfile = gCfg.profiles[idx].name
|
||||
saveConfig(gCfg)
|
||||
refreshTray()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Procédure de fenêtre
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc wndProc(hwnd: HWND, msg: UINT, wParam: WPARAM, lParam: LPARAM): LRESULT {.stdcall.} =
|
||||
case msg
|
||||
of WmTrayIcon:
|
||||
let mouseMsg = int(lParam and 0xFFFF)
|
||||
if mouseMsg == WM_RBUTTONUP or mouseMsg == WM_CONTEXTMENU:
|
||||
showTrayMenu(hwnd, gCfg, gTimer)
|
||||
elif mouseMsg == WM_LBUTTONDBLCLK:
|
||||
toggleStartPause()
|
||||
return 0
|
||||
of WM_COMMAND:
|
||||
handleCommand(int(wParam))
|
||||
return 0
|
||||
of WM_TIMER:
|
||||
if wParam == TimerId:
|
||||
onTick()
|
||||
return 0
|
||||
of WM_HOTKEY:
|
||||
if wParam == int64(HotkeyId):
|
||||
toggleStartPause()
|
||||
return 0
|
||||
of WmUpdateAvailable:
|
||||
let ver = $cast[cstring](addr gUpdateLatest[0])
|
||||
showToast(gHwnd, S.notifUpdateTitle, S.notifUpdateBody % [ver])
|
||||
return 0
|
||||
of WM_DESTROY:
|
||||
PostQuitMessage(0)
|
||||
return 0
|
||||
else:
|
||||
discard
|
||||
return DefWindowProc(hwnd, msg, wParam, lParam)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Point d'entrée
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
proc isAlreadyRunning(): bool =
|
||||
## Garde mono-instance via un mutex nommé.
|
||||
acquireSingleInstance(SingleInstanceMutexName)
|
||||
|
||||
proc main() =
|
||||
if isAlreadyRunning():
|
||||
discard MessageBox(0, "TrayTempo est déjà en cours d'exécution.",
|
||||
"TrayTempo", MB_OK or MB_ICONINFORMATION)
|
||||
return
|
||||
|
||||
gCfg = loadConfig()
|
||||
gStats = loadStats()
|
||||
gTimer = initTimer()
|
||||
|
||||
# Premier lancement : assistant de configuration guidée.
|
||||
let firstRun = not fileExists(configPath())
|
||||
if firstRun:
|
||||
setLang(detectSystemLang())
|
||||
var cfg = defaultConfig(resolveLang(gCfg))
|
||||
cfg.lang = "auto"
|
||||
if showFirstRunWizard(cfg):
|
||||
gCfg = cfg
|
||||
saveConfig(gCfg)
|
||||
|
||||
setLang(resolveLang(gCfg))
|
||||
|
||||
let hInstance = GetModuleHandleW(nil)
|
||||
|
||||
var wc: WNDCLASSW
|
||||
wc.lpfnWndProc = cast[WNDPROC](wndProc)
|
||||
wc.hInstance = hInstance
|
||||
wc.lpszClassName = L"TrayTempoWindowClass"
|
||||
wc.hIcon = phaseIcon(phIdle)
|
||||
if RegisterClass(&wc) == 0:
|
||||
# La classe peut déjà être enregistrée ; on poursuit quand même.
|
||||
discard
|
||||
|
||||
gHwnd = CreateWindowExW(0, L"TrayTempoWindowClass", L"TrayTempo",
|
||||
WS_OVERLAPPED, 0, 0, 0, 0, 0, 0, hInstance, nil)
|
||||
if gHwnd == 0:
|
||||
discard MessageBox(0, "Impossible de créer la fenêtre de l'application.",
|
||||
"TrayTempo", MB_ICONERROR)
|
||||
return
|
||||
|
||||
# Icône dans le tray.
|
||||
discard addTrayIcon(gHwnd, phaseIcon(gTimer.phase), tooltipText(gCfg, gTimer))
|
||||
|
||||
# Minuteur (1 tick par seconde).
|
||||
discard SetTimer(gHwnd, TimerId, 1000, nil)
|
||||
|
||||
# Raccourci global configurable.
|
||||
applyHotkey()
|
||||
|
||||
# Fenêtre flottante (si activée dans la configuration).
|
||||
if gCfg.floatingWindow:
|
||||
showFloating()
|
||||
updateFloating(floatingText())
|
||||
|
||||
# Vérification de mise à jour en arrière-plan (désactivée si UpdateUrl vide).
|
||||
if UpdateUrl.len > 0:
|
||||
var updateThread: Thread[void]
|
||||
createThread(updateThread, updateCheckThread)
|
||||
|
||||
# Boucle de messages.
|
||||
var msg: MSG
|
||||
while GetMessage(&msg, 0, 0, 0) != 0:
|
||||
TranslateMessage(&msg)
|
||||
DispatchMessage(&msg)
|
||||
|
||||
when isMainModule:
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
## updater.nim
|
||||
## ============
|
||||
## Vérification de mise à jour. Deux fonctions pures (`parseVersion`,
|
||||
## `isNewer`) sont testables sans réseau ; `fetchLatestVersion` interroge une
|
||||
## URL distante (endpoint qui renvoie une version "x.y.z" en texte brut).
|
||||
##
|
||||
## L'URL est configurable. Laissez `UpdateUrl` vide pour désactiver la
|
||||
## vérification automatique.
|
||||
|
||||
import std/[httpclient, strutils]
|
||||
|
||||
# URL du fichier de version (texte brut, ex. "0.3.0"). Mettre à "" pour
|
||||
# désactiver la vérification de mise à jour.
|
||||
const UpdateUrl* = ""
|
||||
|
||||
proc parseVersion*(v: string): seq[int] =
|
||||
## Découpe "1.2.3" en [1, 2, 3]. Les composants non numériques valent 0.
|
||||
for part in v.strip().split('.'):
|
||||
try:
|
||||
result.add parseInt(part)
|
||||
except ValueError:
|
||||
result.add 0
|
||||
|
||||
proc isNewer*(current, latest: string): bool =
|
||||
## Retourne true si `latest` est strictement plus récente que `current`.
|
||||
let c = parseVersion(current)
|
||||
let l = parseVersion(latest)
|
||||
let n = max(c.len, l.len)
|
||||
for i in 0 ..< n:
|
||||
let cv = if i < c.len: c[i] else: 0
|
||||
let lv = if i < l.len: l[i] else: 0
|
||||
if lv > cv: return true
|
||||
if lv < cv: return false
|
||||
false
|
||||
|
||||
proc fetchLatestVersion*(url = UpdateUrl): string =
|
||||
## Récupère la dernière version depuis `url`. Retourne "" en cas d'échec
|
||||
## (réseau, timeout, URL vide).
|
||||
if url.len == 0:
|
||||
return ""
|
||||
try:
|
||||
let client = newHttpClient(timeout = 5000)
|
||||
defer: client.close()
|
||||
result = client.getContent(url).strip()
|
||||
except:
|
||||
result = ""
|
||||
Reference in New Issue
Block a user