generated from LouisMazin/PythonApplicationTemplate
Initial commit
This commit is contained in:
commit
8351f32811
89
.gitignore
vendored
Normal file
89
.gitignore
vendored
Normal file
@ -0,0 +1,89 @@
|
||||
# Fichiers système
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Dossiers de build et distributions
|
||||
/build/
|
||||
/dist/
|
||||
/out/
|
||||
/release/
|
||||
*.exe
|
||||
*.msi
|
||||
*.dmg
|
||||
*.pkg
|
||||
*.app
|
||||
|
||||
# Fichiers d'environnement et de configuration
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
LINenv*/
|
||||
WINenv*/
|
||||
MACenv*/
|
||||
|
||||
# Fichiers de dépendances
|
||||
/node_modules/
|
||||
/.pnp/
|
||||
.pnp.js
|
||||
/vendor/
|
||||
/packages/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Logs et bases de données
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Fichiers d'IDE et d'éditeurs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
.vs/
|
||||
*.user
|
||||
*.userosscache
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Fichiers temporaires
|
||||
/tmp/
|
||||
/temp/
|
||||
*.tmp
|
||||
*.bak
|
||||
*~
|
||||
|
||||
# Fichiers spécifiques à l'application
|
||||
# Ne pas ignorer le fichier config.json
|
||||
!config.json
|
||||
|
||||
# Dossier de cache
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
|
||||
# Fichiers de couverture de test
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Autres
|
||||
.vercel
|
||||
.next
|
||||
.nuxt
|
||||
.serverless/
|
||||
|
||||
108
BUILD.spec
Normal file
108
BUILD.spec
Normal file
@ -0,0 +1,108 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from os import getenv
|
||||
|
||||
# --- Load config.json ---
|
||||
config_path = Path("../config.json")
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
|
||||
# --- Extract values ---
|
||||
python_version = config.get("python_version", "3.x")
|
||||
app_name = config.get("app_name", "Application")
|
||||
|
||||
# --- Construct dynamic name ---
|
||||
name = f"{app_name}"
|
||||
|
||||
# --- Optional icon path ---
|
||||
icon = getenv("ICON_PATH", "")
|
||||
|
||||
# --- Data files to bundle ---
|
||||
datas = [
|
||||
("data/assets/*", "data/assets/"),
|
||||
("data/", "data/"),
|
||||
("config.json", "."),
|
||||
(".env", "."),
|
||||
]
|
||||
binaries = []
|
||||
|
||||
# --- Analysis ---
|
||||
a = Analysis(
|
||||
["main.py"],
|
||||
pathex=[],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
# --- EXE common to all platforms ---
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name=name,
|
||||
icon=icon if icon else None,
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False, # pas de terminal par défaut
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=(sys.platform == "darwin"), # utile sur macOS GUI
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
# --- Platform-specific targets ---
|
||||
if sys.platform == "darwin":
|
||||
# macOS: wrap EXE in a .app bundle
|
||||
app = BUNDLE(
|
||||
exe,
|
||||
name=f"{name}.app",
|
||||
icon=icon if icon else None,
|
||||
bundle_identifier=f"com.example.{app_name.lower()}"
|
||||
)
|
||||
# La dernière variable = objet final PyInstaller doit construire
|
||||
coll = app
|
||||
|
||||
elif sys.platform.startswith("linux"):
|
||||
# Linux: keep binary + generate .desktop file
|
||||
coll = exe
|
||||
|
||||
dist_dir = Path("build")
|
||||
dist_bin = dist_dir / name
|
||||
desktop_file = dist_dir / f"{app_name}.desktop"
|
||||
|
||||
desktop_content = f"""[Desktop Entry]
|
||||
Type=Application
|
||||
Name={app_name}
|
||||
Exec={dist_bin}
|
||||
Icon={icon if icon else "application-default-icon"}
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
"""
|
||||
# Création post-build
|
||||
os.makedirs(dist_dir, exist_ok=True)
|
||||
with open(desktop_file, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_content)
|
||||
os.chmod(desktop_file, 0o755)
|
||||
|
||||
else:
|
||||
# Windows: just the exe
|
||||
coll = exe
|
||||
79
app/core/alert_manager.py
Normal file
79
app/core/alert_manager.py
Normal file
@ -0,0 +1,79 @@
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
class AlertManager:
|
||||
|
||||
def __init__(self, language_manager, theme_manager) -> None:
|
||||
self.language_manager = language_manager
|
||||
self.theme_manager = theme_manager
|
||||
|
||||
def show_info(self, info_text: str, parent=None) -> None:
|
||||
info_title = self.language_manager.get_text("information")
|
||||
|
||||
QMessageBox.information(parent, info_title, info_text)
|
||||
|
||||
def show_success(self, success_key: str, parent=None) -> None:
|
||||
success_title = self.language_manager.get_text("success")
|
||||
success_text = self.language_manager.get_text(success_key)
|
||||
|
||||
QMessageBox.information(parent, success_title, success_text)
|
||||
|
||||
def show_error(self, error_key: str, parent=None) -> None:
|
||||
|
||||
error_title = self.language_manager.get_text("error")
|
||||
error_text = self.language_manager.get_text(error_key)
|
||||
|
||||
QMessageBox.critical(parent, error_title, error_text)
|
||||
|
||||
def show_choice(self, message: str, parent=None) -> bool:
|
||||
"""
|
||||
Affiche une boîte de dialogue Oui/Non.
|
||||
Si detailed_text est fourni, l'ajoute comme texte détaillé (affichable par l'utilisateur).
|
||||
"""
|
||||
box = QMessageBox(parent)
|
||||
box.setWindowTitle(self.language_manager.get_text("confirmation"))
|
||||
box.setText(message)
|
||||
box.setIcon(QMessageBox.Icon.Question)
|
||||
yes = box.addButton(QMessageBox.StandardButton.Yes)
|
||||
no = box.addButton(QMessageBox.StandardButton.No)
|
||||
yes.setText(self.language_manager.get_text("yes"))
|
||||
no.setText(self.language_manager.get_text("no"))
|
||||
box.setDefaultButton(yes)
|
||||
box.exec()
|
||||
return box.clickedButton() == yes
|
||||
|
||||
def show_choice_with_details(self, message: str, parent=None, details_callback=None) -> bool:
|
||||
"""
|
||||
Affiche une boîte de dialogue Oui/Non avec un bouton Détails.
|
||||
Si detailed_text est fourni, l'ajoute comme texte détaillé (affichable par l'utilisateur).
|
||||
Le callback details_callback est appelé lorsque l'utilisateur clique sur le bouton Détails.
|
||||
"""
|
||||
box = QMessageBox(parent)
|
||||
box.setWindowTitle(self.language_manager.get_text("confirmation"))
|
||||
box.setText(message)
|
||||
box.setIcon(QMessageBox.Icon.Question)
|
||||
yes = box.addButton(QMessageBox.StandardButton.Yes)
|
||||
no = box.addButton(QMessageBox.StandardButton.No)
|
||||
details = box.addButton(self.language_manager.get_text("details"), QMessageBox.ButtonRole.ActionRole)
|
||||
yes.setText(self.language_manager.get_text("yes"))
|
||||
no.setText(self.language_manager.get_text("no"))
|
||||
box.setDefaultButton(yes)
|
||||
|
||||
def on_button_clicked(button):
|
||||
if button == details and details_callback:
|
||||
box.setResult(QMessageBox.StandardButton.NoButton)
|
||||
details_callback()
|
||||
if not box.isVisible():
|
||||
box.show()
|
||||
|
||||
box.buttonClicked.connect(on_button_clicked)
|
||||
|
||||
while True:
|
||||
box.exec()
|
||||
clicked_button = box.clickedButton()
|
||||
|
||||
# Si c'est le bouton détails, on continue la boucle sans fermer
|
||||
if clicked_button == details:
|
||||
continue
|
||||
# Sinon, on sort de la boucle et retourne le résultat
|
||||
else:
|
||||
return clicked_button == yes
|
||||
40
app/core/language_manager.py
Normal file
40
app/core/language_manager.py
Normal file
@ -0,0 +1,40 @@
|
||||
from json import JSONDecodeError, load
|
||||
from os import listdir, path
|
||||
from typing import Dict
|
||||
import app.utils.paths as paths
|
||||
from app.core.settings_manager import SettingsManager
|
||||
|
||||
class LanguageManager:
|
||||
"""
|
||||
Gère les traductions pour l'application.
|
||||
Charge les fichiers JSON de langue dans data/lang/, permet
|
||||
de changer la langue courante, et récupérer des textes traduits.
|
||||
"""
|
||||
def __init__(self, settings_manager: SettingsManager) -> None:
|
||||
self.translations: Dict[str, Dict[str, str]] = {}
|
||||
self.settings_manager: SettingsManager = settings_manager
|
||||
|
||||
self.load_all_translations()
|
||||
|
||||
def load_all_translations(self) -> None:
|
||||
"""
|
||||
Charge tous les fichiers JSON dans data/lang/ comme dictionnaires.
|
||||
"""
|
||||
lang_dir: str = paths.get_lang_path()
|
||||
|
||||
for filename in listdir(lang_dir):
|
||||
if filename.endswith(".json"):
|
||||
lang_code: str = filename[:-5] # strip .json
|
||||
file_path: str = path.join(lang_dir, filename)
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
self.translations[lang_code] = load(f)
|
||||
except JSONDecodeError as e:
|
||||
pass
|
||||
|
||||
def get_text(self, key: str) -> str:
|
||||
"""
|
||||
Retourne le texte traduit correspondant à la clé dans la langue courante.
|
||||
Si la clé n'existe pas, retourne la clé elle-même.
|
||||
"""
|
||||
return self.translations.get(self.settings_manager.get_language(), {}).get(key, key)
|
||||
220
app/core/license_manager.py
Normal file
220
app/core/license_manager.py
Normal file
@ -0,0 +1,220 @@
|
||||
import hashlib
|
||||
import platform
|
||||
import uuid
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import app.utils.paths as paths
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(paths.resource_path(".env"))
|
||||
|
||||
class LicenseManager(QObject):
|
||||
"""Gestionnaire de licences avec liaison matérielle"""
|
||||
|
||||
license_activated = pyqtSignal(str) # Signal émis lors de l'activation
|
||||
license_expired = pyqtSignal()
|
||||
|
||||
def __init__(self, settings_manager):
|
||||
super().__init__()
|
||||
self.settings_manager = settings_manager
|
||||
self.licensing_enabled = settings_manager.get_config("enable_licensing")
|
||||
|
||||
# Si le système de licence est désactivé, initialiser en mode gratuit
|
||||
if not self.licensing_enabled:
|
||||
self.hardware_id = None
|
||||
self.license_key = ""
|
||||
self.license_data = None
|
||||
return
|
||||
|
||||
# Charger l'URL de l'API depuis .env
|
||||
self.api_url = os.getenv("LICENSE_API_URL")
|
||||
if not self.api_url:
|
||||
raise ValueError("LICENSE_API_URL non définie dans le fichier .env")
|
||||
|
||||
self.hardware_id = self._get_hardware_id()
|
||||
|
||||
# Charger la licence sauvegardée
|
||||
self.license_key = settings_manager.settings.value("license_key", "")
|
||||
self.license_data = None
|
||||
|
||||
if self.license_key:
|
||||
self._load_license_data()
|
||||
|
||||
def _get_hardware_id(self) -> str:
|
||||
"""Génère un identifiant unique basé sur le matériel"""
|
||||
# Combine plusieurs éléments matériels pour un ID unique
|
||||
mac = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff)
|
||||
for elements in range(0, 2*6, 2)][::-1])
|
||||
system = platform.system()
|
||||
machine = platform.machine()
|
||||
processor = platform.processor()
|
||||
|
||||
# Créer un hash unique
|
||||
unique_string = f"{mac}{system}{machine}{processor}"
|
||||
return hashlib.sha256(unique_string.encode()).hexdigest()
|
||||
|
||||
def get_hardware_id(self) -> str:
|
||||
"""Retourne le hardware ID pour l'afficher à l'utilisateur"""
|
||||
return self.hardware_id
|
||||
|
||||
def activate_license(self, license_key: str) -> Dict:
|
||||
"""
|
||||
Active une licence avec le serveur
|
||||
|
||||
Returns:
|
||||
dict: {"success": bool, "message": str, "data": dict}
|
||||
"""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.api_url}/activate",
|
||||
json={
|
||||
"license_key": license_key,
|
||||
"hardware_id": self.hardware_id,
|
||||
"app_version": self.settings_manager.get_config("app_version"),
|
||||
"platform": platform.system()
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200 and data.get("success"):
|
||||
# Sauvegarder la licence
|
||||
self.license_key = license_key
|
||||
self.license_data = data.get("license_data", {})
|
||||
self.settings_manager.settings.setValue("license_key", license_key)
|
||||
self.settings_manager.settings.setValue("license_data", self.license_data)
|
||||
|
||||
self.license_activated.emit(license_key)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Licence activée avec succès",
|
||||
"data": self.license_data
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": data.get("message", "Erreur d'activation"),
|
||||
"data": None
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Erreur de connexion au serveur: {str(e)}",
|
||||
"data": None
|
||||
}
|
||||
|
||||
def verify_license(self) -> bool:
|
||||
"""Vérifie la validité de la licence avec le serveur"""
|
||||
if not self.license_key:
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.api_url}/verify",
|
||||
json={
|
||||
"license_key": self.license_key,
|
||||
"hardware_id": self.hardware_id
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code == 200 and data.get("valid"):
|
||||
self.license_data = data.get("license_data", {})
|
||||
self.settings_manager.settings.setValue("license_data", self.license_data)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
# En cas d'erreur réseau, utiliser les données en cache
|
||||
return self._verify_offline()
|
||||
|
||||
def _verify_offline(self) -> bool:
|
||||
"""Vérification hors ligne basique"""
|
||||
if not self.license_key or not self.license_data:
|
||||
return False
|
||||
|
||||
# Vérifier que le hardware_id correspond
|
||||
if self.license_data.get("hardware_id") != self.hardware_id:
|
||||
return False
|
||||
|
||||
# Vérifier la date d'expiration si applicable
|
||||
expires_at = self.license_data.get("expires_at")
|
||||
if expires_at:
|
||||
expiry_date = datetime.fromisoformat(expires_at)
|
||||
if datetime.now() > expiry_date:
|
||||
self.license_expired.emit()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _load_license_data(self):
|
||||
"""Charge les données de licence depuis les settings"""
|
||||
self.license_data = self.settings_manager.settings.value("license_data", {})
|
||||
|
||||
def is_activated(self) -> bool:
|
||||
"""Vérifie si l'application est activée"""
|
||||
# Si le système de licence est désactivé, toujours retourner False
|
||||
if not self.licensing_enabled:
|
||||
return False
|
||||
|
||||
return bool(self.license_key) and self.verify_license()
|
||||
|
||||
def get_license_type(self) -> str:
|
||||
"""Retourne le type de licence (free, premium, etc.)"""
|
||||
# Si le système de licence est désactivé, retourner "free"
|
||||
if not self.licensing_enabled:
|
||||
return None
|
||||
|
||||
if not self.license_data:
|
||||
return None
|
||||
return self.license_data.get("type", None)
|
||||
|
||||
def is_feature_available(self, feature_id: str) -> bool:
|
||||
"""
|
||||
Vérifie si une fonctionnalité est disponible
|
||||
|
||||
Args:
|
||||
feature_id: Identifiant de la fonctionnalité (ex: "advanced_export")
|
||||
"""
|
||||
# Si le système de licence est désactivé, toutes les fonctionnalités sont disponibles
|
||||
if not self.licensing_enabled:
|
||||
return True
|
||||
|
||||
# Si pas de licence, vérifier dans la config des features gratuites
|
||||
if not self.is_activated():
|
||||
free_features = self.settings_manager.get_config("features_by_license").get(None, [])
|
||||
return feature_id in free_features
|
||||
|
||||
# Vérifier les features autorisées par la licence
|
||||
license_type = self.get_license_type()
|
||||
features_by_type = self.settings_manager.get_config("features_by_license")
|
||||
allowed_features = features_by_type.get(license_type, [])
|
||||
|
||||
return feature_id in allowed_features
|
||||
|
||||
def get_license_info(self) -> Dict:
|
||||
"""Retourne les informations de la licence"""
|
||||
if not self.license_data:
|
||||
return {
|
||||
"type": None,
|
||||
"status": "inactive",
|
||||
"expires_at": None
|
||||
}
|
||||
|
||||
return {
|
||||
"type": self.get_license_type(),
|
||||
"status": "active" if self.is_activated() else "inactive",
|
||||
"expires_at": self.license_data.get("expires_at"),
|
||||
"email": self.license_data.get("email"),
|
||||
"activated_at": self.license_data.get("activated_at")
|
||||
}
|
||||
51
app/core/main_manager.py
Normal file
51
app/core/main_manager.py
Normal file
@ -0,0 +1,51 @@
|
||||
from app.core.observer_manager import ObserverManager, NotificationType
|
||||
from app.core.language_manager import LanguageManager
|
||||
from app.core.theme_manager import ThemeManager
|
||||
from app.core.settings_manager import SettingsManager
|
||||
from app.core.alert_manager import AlertManager
|
||||
from app.core.update_manager import UpdateManager
|
||||
from app.core.license_manager import LicenseManager
|
||||
|
||||
from typing import Optional
|
||||
|
||||
class MainManager:
|
||||
_instance: Optional['MainManager'] = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
if MainManager._instance is not None:
|
||||
raise Exception("This class is a singleton!")
|
||||
else:
|
||||
MainManager._instance = self
|
||||
self.observer_manager: ObserverManager = ObserverManager()
|
||||
self.theme_manager: ThemeManager = ThemeManager()
|
||||
self.settings_manager: SettingsManager = SettingsManager(self.observer_manager, self.theme_manager)
|
||||
self.language_manager: LanguageManager = LanguageManager(self.settings_manager)
|
||||
self.alert_manager: AlertManager = AlertManager(self.language_manager, self.theme_manager)
|
||||
self.update_manager: UpdateManager = UpdateManager(self.settings_manager, self.language_manager, self.alert_manager)
|
||||
self.license_manager: LicenseManager = LicenseManager(self.settings_manager)
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'MainManager':
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def get_observer_manager(self) -> ObserverManager:
|
||||
return self.observer_manager
|
||||
|
||||
def get_theme_manager(self) -> ThemeManager:
|
||||
return self.theme_manager
|
||||
|
||||
def get_settings_manager(self) -> SettingsManager:
|
||||
return self.settings_manager
|
||||
|
||||
def get_language_manager(self) -> LanguageManager:
|
||||
return self.language_manager
|
||||
|
||||
def get_alert_manager(self) -> AlertManager:
|
||||
return self.alert_manager
|
||||
|
||||
def get_update_manager(self) -> UpdateManager:
|
||||
return self.update_manager
|
||||
|
||||
def get_license_manager(self) -> LicenseManager:
|
||||
return self.license_manager
|
||||
39
app/core/observer_manager.py
Normal file
39
app/core/observer_manager.py
Normal file
@ -0,0 +1,39 @@
|
||||
from typing import Callable, Dict, List, Any
|
||||
|
||||
class NotificationType:
|
||||
THEME = 0
|
||||
LANGUAGE = 1
|
||||
|
||||
class ObserverManager:
|
||||
"""
|
||||
Gestionnaire d'observateurs pour gérer les événements et notifier les abonnés.
|
||||
Chaque événement est identifié par une clé (string), et les abonnés sont des callbacks.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.observers: Dict[str, List[Callable[[Any], None]]] = {}
|
||||
|
||||
def subscribe(self, event: str, callback: Callable[[Any], None]) -> None:
|
||||
"""
|
||||
Ajoute un callback à la liste des abonnés pour l'événement donné.
|
||||
"""
|
||||
if event not in self.observers:
|
||||
self.observers[event] = []
|
||||
if callback not in self.observers[event]:
|
||||
self.observers[event].append(callback)
|
||||
|
||||
def unsubscribe(self, event: str, callback: Callable[[Any], None]) -> None:
|
||||
"""
|
||||
Retire un callback de la liste des abonnés pour l'événement donné.
|
||||
"""
|
||||
if event in self.observers and callback in self.observers[event]:
|
||||
self.observers[event].remove(callback)
|
||||
if not self.observers[event]:
|
||||
del self.observers[event]
|
||||
|
||||
def notify(self, event: NotificationType) -> None:
|
||||
"""
|
||||
Notifie tous les abonnés de l'événement donné, en leur passant la donnée.
|
||||
"""
|
||||
if event in self.observers:
|
||||
for callback in self.observers[event]:
|
||||
callback()
|
||||
181
app/core/settings_manager.py
Normal file
181
app/core/settings_manager.py
Normal file
@ -0,0 +1,181 @@
|
||||
from PyQt6.QtCore import QSettings
|
||||
from app.core.observer_manager import ObserverManager, NotificationType
|
||||
from app.core.theme_manager import ThemeManager
|
||||
from os import path
|
||||
import app.utils.paths as paths
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
# Configure logging
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
class SettingsManager:
|
||||
"""
|
||||
Gestion des paramètres utilisateurs avec sauvegarde persistante via QSettings.
|
||||
Notifie les changements via ObserverManager.
|
||||
"""
|
||||
def __init__(self, observer_manager: ObserverManager, theme_manager: ThemeManager) -> None:
|
||||
self.observer_manager: ObserverManager = observer_manager
|
||||
self.theme_manager: ThemeManager = theme_manager
|
||||
|
||||
# Load default settings
|
||||
self.default_settings: Dict[str, Any] = self._load_default_settings()
|
||||
# Load config
|
||||
self.config: Dict[str, Any] = self._load_config()
|
||||
|
||||
# Initialize QSettings with error handling
|
||||
self.settings: QSettings = self._initialize_qsettings()
|
||||
|
||||
# Set initial theme
|
||||
try:
|
||||
self.theme_manager.set_theme(self.get_theme())
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting initial theme: {e}")
|
||||
self.theme_manager.set_theme(self.fallback_settings["theme"])
|
||||
|
||||
def _load_default_settings(self) -> Dict[str, Any]:
|
||||
"""Load default settings with error handling"""
|
||||
try:
|
||||
defaults_path = path.join(paths.get_data_dir(), "others", "defaults_settings.json")
|
||||
if path.exists(defaults_path):
|
||||
with open(defaults_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
return settings
|
||||
else:
|
||||
logger.warning(f"defaults_settings.json not found")
|
||||
return {}
|
||||
except (json.JSONDecodeError, FileNotFoundError, KeyError, UnicodeDecodeError) as e:
|
||||
logger.error(f"Error loading default settings: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error loading default settings: {e}")
|
||||
return {}
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load config.json with error handling"""
|
||||
try:
|
||||
config_path = paths.resource_path("config.json")
|
||||
if path.exists(config_path):
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
return config
|
||||
else:
|
||||
logger.warning("config.json not found")
|
||||
return {}
|
||||
except (json.JSONDecodeError, FileNotFoundError, KeyError, UnicodeDecodeError) as e:
|
||||
logger.error(f"Error loading config: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error loading config: {e}")
|
||||
return {}
|
||||
|
||||
def _initialize_qsettings(self) -> QSettings:
|
||||
"""Initialize QSettings with error handling"""
|
||||
try:
|
||||
app_name = self.get_config("app_name")
|
||||
user_data_dir = paths.get_user_data_dir(app_name)
|
||||
settings_path = path.join(user_data_dir, f"{app_name}.ini")
|
||||
return QSettings(settings_path, QSettings.Format.IniFormat)
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing QSettings: {e}")
|
||||
# Fallback to memory-only settings
|
||||
return QSettings()
|
||||
|
||||
# Config
|
||||
def get_config(self, key: str) -> Any:
|
||||
"""Get configuration value by key"""
|
||||
try:
|
||||
return self.config.get(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting config key '{key}': {e}")
|
||||
return None
|
||||
|
||||
# Theme
|
||||
def get_theme(self) -> str:
|
||||
"""Get the current theme"""
|
||||
try:
|
||||
return self.settings.value("theme", self.default_settings.get("theme"))
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting theme: {e}")
|
||||
return ""
|
||||
|
||||
def set_theme(self, mode: str) -> None:
|
||||
"""Set the application theme"""
|
||||
try:
|
||||
if mode != self.get_theme():
|
||||
self.settings.setValue("theme", mode)
|
||||
self.theme_manager.set_theme(mode)
|
||||
self.observer_manager.notify(NotificationType.THEME)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting theme: {e}")
|
||||
|
||||
# Language
|
||||
def get_language(self) -> str:
|
||||
"""Get the current language"""
|
||||
try:
|
||||
return self.settings.value("lang", self.default_settings.get("lang"))
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting language: {e}")
|
||||
return ""
|
||||
|
||||
def set_language(self, lang_code: str) -> None:
|
||||
"""Set the application language"""
|
||||
try:
|
||||
if lang_code != self.get_language():
|
||||
self.settings.setValue("lang", lang_code)
|
||||
self.observer_manager.notify(NotificationType.LANGUAGE)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting language: {e}")
|
||||
|
||||
# Window size and maximized
|
||||
def get_window_size(self) -> Dict[str, int]:
|
||||
"""Get the window size"""
|
||||
try:
|
||||
size = self.settings.value("window_size", self.default_settings.get("window_size"))
|
||||
# Validate window size values
|
||||
if isinstance(size, dict) and "width" in size and "height" in size:
|
||||
width = int(size["width"]) if size["width"] > 0 else 1000
|
||||
height = int(size["height"]) if size["height"] > 0 else 600
|
||||
return {"width": width, "height": height}
|
||||
else:
|
||||
return {"width": 1000, "height": 600}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting window size: {e}")
|
||||
return {"width": 1000, "height": 600}
|
||||
|
||||
def set_window_size(self, width: int, height: int) -> None:
|
||||
"""Set the window size"""
|
||||
try:
|
||||
# Validate input values
|
||||
if width <= 0 or height <= 0:
|
||||
logger.warning(f"Invalid window size: {width}x{height}")
|
||||
return
|
||||
|
||||
current_size = self.get_window_size()
|
||||
if current_size["width"] != width or current_size["height"] != height:
|
||||
new_size = {"width": width, "height": height}
|
||||
self.settings.setValue("window_size", new_size)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting window size: {e}")
|
||||
|
||||
def get_maximized(self) -> bool:
|
||||
"""Check if the window is maximized"""
|
||||
try:
|
||||
value = self.settings.value("maximized", self.default_settings.get("maximized"))
|
||||
if isinstance(value, str):
|
||||
return value.lower() == "true"
|
||||
elif isinstance(value, bool):
|
||||
return value
|
||||
else:
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting maximized state: {e}")
|
||||
return False
|
||||
|
||||
def set_maximized(self, maximized: bool) -> None:
|
||||
"""Set the window maximized state"""
|
||||
try:
|
||||
self.settings.setValue("maximized", maximized)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting maximized state: {e}")
|
||||
218
app/core/theme_manager.py
Normal file
218
app/core/theme_manager.py
Normal file
@ -0,0 +1,218 @@
|
||||
import app.utils.paths as paths
|
||||
import os, json
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
class Theme:
|
||||
def __init__(self, name: str, colors: Dict[str, str]) -> None:
|
||||
self.name: str = name
|
||||
self.colors: Dict[str, str] = colors
|
||||
|
||||
def get_color(self, element: str) -> str:
|
||||
return self.colors.get(element, "#FFFFFF")
|
||||
|
||||
class ThemeManager:
|
||||
|
||||
def __init__(self) -> None:
|
||||
theme_path: str = os.path.join(paths.get_data_dir(), "themes")
|
||||
self.themes: List[Theme] = []
|
||||
for theme_file in os.listdir(theme_path):
|
||||
if theme_file.endswith(".json"):
|
||||
with open(os.path.join(theme_path, theme_file), 'r', encoding='utf-8') as f:
|
||||
theme_data: Dict[str, Any] = json.load(f)
|
||||
theme: Theme = Theme(theme_data["theme_name"], theme_data["colors"])
|
||||
self.themes.append(theme)
|
||||
self.current_theme: Theme = self.themes[0]
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
if theme != self.current_theme.name:
|
||||
found_theme: Optional[Theme] = next((t for t in self.themes if t.name == theme), None)
|
||||
if found_theme:
|
||||
self.current_theme = found_theme
|
||||
|
||||
def get_theme(self) -> Theme:
|
||||
return self.current_theme
|
||||
def get_themes(self) -> List[Theme]:
|
||||
return self.themes
|
||||
def get_sheet(self) -> str:
|
||||
return f"""
|
||||
QWidget {{
|
||||
background-color: {self.current_theme.get_color("background_secondary_color")};
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
}}
|
||||
QLabel {{
|
||||
background-color: transparent;
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
font-size: 20px;
|
||||
}}
|
||||
QPushButton {{
|
||||
background-color: {self.current_theme.get_color("primary_color")};
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {self.current_theme.get_color("primary_hover_color")};
|
||||
}}
|
||||
QProgressBar {{
|
||||
border: 1px solid {self.current_theme.get_color("border_color")};
|
||||
border-radius: 5px;
|
||||
background-color: {self.current_theme.get_color("background_secondary_color")};
|
||||
text-align: center;
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background-color: {self.current_theme.get_color("primary_color")};
|
||||
border-radius: 3px;
|
||||
}}
|
||||
QTextEdit {{
|
||||
border: 2px solid {self.current_theme.get_color("border_color")};
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
font-size: 14px;
|
||||
background-color: {self.current_theme.get_color("background_tertiary_color")};
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
}}
|
||||
QLineEdit {{
|
||||
border: 2px solid {self.current_theme.get_color("border_color")};
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
font-size: 14px;
|
||||
background-color: {self.current_theme.get_color("background_tertiary_color")};
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
}}
|
||||
|
||||
QDateEdit {{
|
||||
border: 2px solid {self.current_theme.get_color("border_color")};
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
min-height: 30px;
|
||||
}}
|
||||
QDateEdit::drop-down {{
|
||||
border: none;
|
||||
background: transparent;
|
||||
}}
|
||||
QDateEdit:hover {{
|
||||
border-color: {self.current_theme.get_color("primary_hover_color")};
|
||||
}}
|
||||
QComboBox {{
|
||||
border: 2px solid {self.current_theme.get_color("border_color")};
|
||||
padding: 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
min-height: 30px;
|
||||
}}
|
||||
QComboBox QAbstractItemView {{
|
||||
border-radius: 8px;
|
||||
padding: 0px;
|
||||
outline: none;
|
||||
}}
|
||||
QComboBox QAbstractItemView::item {{
|
||||
padding: 12px 15px;
|
||||
margin: 0px;
|
||||
min-height: 20px;
|
||||
border: 1px solid {self.current_theme.get_color("border_color")};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
QComboBox QAbstractItemView::item:hover {{
|
||||
background-color: {self.current_theme.get_color("background_tertiary_color")};
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
}}
|
||||
QComboBox QAbstractItemView::item:selected {{
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
border: none;
|
||||
background: transparent;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: none;
|
||||
}}
|
||||
QComboBox:hover {{
|
||||
border-color: {self.current_theme.get_color("primary_hover_color")};
|
||||
}}
|
||||
|
||||
#table_combobox {{
|
||||
border: 1px solid {self.current_theme.get_color("border_color")};
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
min-height: 20px;
|
||||
max-height: 28px;
|
||||
}}
|
||||
#table_combobox::drop-down {{
|
||||
border: none;
|
||||
background: transparent;
|
||||
}}
|
||||
#table_combobox::down-arrow {{
|
||||
image: none;
|
||||
}}
|
||||
#table_combobox QAbstractItemView {{
|
||||
border-radius: 4px;
|
||||
padding: 0px;
|
||||
outline: none;
|
||||
}}
|
||||
#table_combobox QAbstractItemView::item {{
|
||||
padding: 8px 10px;
|
||||
margin: 0px;
|
||||
min-height: 16px;
|
||||
border: 1px solid {self.current_theme.get_color("border_color")};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
#table_combobox QAbstractItemView::item:hover {{
|
||||
background-color: {self.current_theme.get_color("background_tertiary_color")};
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
}}
|
||||
#table_combobox QAbstractItemView::item:selected {{
|
||||
color: {self.current_theme.get_color("text_color")};
|
||||
}}
|
||||
|
||||
QSlider::groove:horizontal {{
|
||||
border: 1px solid {self.current_theme.get_color("primary_color")};
|
||||
height: 10px;
|
||||
background: transparent;
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QSlider::sub-page:horizontal {{
|
||||
background: {self.current_theme.get_color("primary_color")};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QSlider::add-page:horizontal {{
|
||||
background: {self.current_theme.get_color("background_tertiary_color")};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QSlider::handle:horizontal {{
|
||||
background: white;
|
||||
border: 2px solid {self.current_theme.get_color("primary_color")};
|
||||
width: 14px;
|
||||
margin: -4px 0;
|
||||
border-radius: 7px;
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
border: none;
|
||||
background: {self.current_theme.get_color("background_tertiary_color")};
|
||||
width: 8px;
|
||||
margin: 0px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {self.current_theme.get_color("primary_color")};
|
||||
border-radius: 4px;
|
||||
min-height: 20px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{
|
||||
background: {self.current_theme.get_color("primary_hover_color")};
|
||||
}}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
|
||||
border: none;
|
||||
background: none;
|
||||
height: 0px;
|
||||
}}
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
|
||||
background: none;
|
||||
}}
|
||||
#tab_bar {{
|
||||
background-color: {self.current_theme.get_color("background_color")};
|
||||
}}
|
||||
"""
|
||||
205
app/core/update_manager.py
Normal file
205
app/core/update_manager.py
Normal file
@ -0,0 +1,205 @@
|
||||
import requests
|
||||
from packaging import version
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QTextEdit, QPushButton
|
||||
from app.core.alert_manager import AlertManager
|
||||
from app.core.settings_manager import SettingsManager
|
||||
from app.core.language_manager import LanguageManager
|
||||
from app.ui.widgets.loading_bar import LoadingBar
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from typing import List, Dict
|
||||
|
||||
class UpdateManager:
|
||||
def __init__(self, settings_manager: SettingsManager, language_manager: LanguageManager, alert_manager: AlertManager) -> None:
|
||||
self.settings_manager = settings_manager
|
||||
self.language_manager = language_manager
|
||||
self.alert_manager = alert_manager
|
||||
self.repo_url = self.settings_manager.get_config("git_repo")
|
||||
self.app_name = self.settings_manager.get_config("app_name").replace(" ","_")
|
||||
self.app_os = self.settings_manager.get_config("app_os")
|
||||
self.arch = self.settings_manager.get_config("architecture")
|
||||
|
||||
def get_releases_with_asset(self) -> List[Dict]:
|
||||
"""
|
||||
Retourne la liste des releases (dict) qui contiennent un asset correspondant
|
||||
à l'OS/architecture attendus. Chaque dict contient: tag_name, download_url, body.
|
||||
"""
|
||||
releases_list: List[Dict] = []
|
||||
try:
|
||||
if "gitea" in self.repo_url:
|
||||
# Gitea: construire URL API (essai basique)
|
||||
owner_repo = self.repo_url.rstrip("/").split("/")[-2:]
|
||||
api_base = self.repo_url.replace("/" + owner_repo[0] + "/" + owner_repo[1], "/api/v1/repos/" + owner_repo[0] + "/" + owner_repo[1])
|
||||
api_url = api_base + "/releases"
|
||||
resp = requests.get(api_url)
|
||||
resp.raise_for_status()
|
||||
releases = resp.json()
|
||||
else:
|
||||
owner_repo = self.repo_url.rstrip("/").split("/")[-2:]
|
||||
api_url = f"https://api.github.com/repos/{owner_repo[0]}/{owner_repo[1]}/releases"
|
||||
resp = requests.get(api_url)
|
||||
resp.raise_for_status()
|
||||
releases = resp.json()
|
||||
|
||||
expected_filename_frag = f"{self.app_name}-{self.app_os}-{self.arch}"
|
||||
for release in releases:
|
||||
tag = release.get("tag_name") or release.get("name")
|
||||
body = release.get("body", "") or ""
|
||||
for asset in release.get("assets", []):
|
||||
name = asset.get("name", "")
|
||||
if expected_filename_frag in name:
|
||||
downloads = asset.get("browser_download_url") or asset.get("url")
|
||||
releases_list.append({
|
||||
"tag_name": tag,
|
||||
"download_url": downloads,
|
||||
"body": body
|
||||
})
|
||||
break
|
||||
except Exception:
|
||||
# En cas d'erreur, retourner liste vide (on ne lève pas pour ne pas bloquer l'app)
|
||||
return []
|
||||
return releases_list
|
||||
|
||||
def check_for_update(self, parent=None, splash_screen=None) -> bool:
|
||||
current_version = self.settings_manager.get_config("app_version")
|
||||
releases = self.get_releases_with_asset()
|
||||
release = releases[0] if releases else None
|
||||
if release and version.parse(release["tag_name"]) > version.parse(current_version):
|
||||
# Fermer le splash avant d'afficher la boîte de dialogue
|
||||
if splash_screen:
|
||||
splash_screen.hide()
|
||||
|
||||
choice = self.show_update_dialog(releases, current_version, parent)
|
||||
|
||||
if choice:
|
||||
folder = QFileDialog.getExistingDirectory(parent, self.language_manager.get_text("choose_update_folder"))
|
||||
if folder:
|
||||
return self.download(release["download_url"], release["tag_name"], folder, parent)
|
||||
|
||||
# Rouvrir le splash si l'utilisateur a refusé la mise à jour
|
||||
if splash_screen:
|
||||
splash_screen.show()
|
||||
|
||||
return False
|
||||
|
||||
def download(self, download_url, version, folder, parent=None):
|
||||
try:
|
||||
filename = os.path.basename(download_url).replace(".", "-" +version + ".")
|
||||
local_path = os.path.join(folder, filename)
|
||||
resp = requests.get(download_url, stream=True)
|
||||
total = int(resp.headers.get('content-length', 0))
|
||||
|
||||
# Crée une boîte de dialogue avec la barre de chargement
|
||||
dialog = QDialog(parent)
|
||||
dialog.setWindowTitle(self.language_manager.get_text("update"))
|
||||
layout = QVBoxLayout(dialog)
|
||||
loading_bar = LoadingBar(self.language_manager.get_text("downloading_update"), dialog)
|
||||
layout.addWidget(loading_bar)
|
||||
dialog.setModal(True)
|
||||
|
||||
# Variable pour tracker si le téléchargement a été annulé
|
||||
download_cancelled = False
|
||||
|
||||
def on_dialog_rejected():
|
||||
nonlocal download_cancelled
|
||||
download_cancelled = True
|
||||
|
||||
dialog.rejected.connect(on_dialog_rejected)
|
||||
dialog.show()
|
||||
|
||||
downloaded = 0
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
QApplication.processEvents()
|
||||
if download_cancelled:
|
||||
f.truncate(0)
|
||||
f.close()
|
||||
break
|
||||
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
percent = int(downloaded * 100 / total) if total else 0
|
||||
loading_bar.set_progress(percent)
|
||||
|
||||
dialog.close()
|
||||
|
||||
if download_cancelled:
|
||||
os.remove(local_path, dir_fd=None)
|
||||
self.alert_manager.show_info(self.language_manager.get_text("update_aborted"), parent=parent)
|
||||
return False
|
||||
|
||||
msg = self.language_manager.get_text("update_downloaded").replace("{local_path}", local_path)
|
||||
self.alert_manager.show_success(msg, parent=parent)
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
os.startfile(local_path)
|
||||
else:
|
||||
subprocess.Popen(["chmod", "+x", local_path])
|
||||
subprocess.Popen([local_path])
|
||||
return True
|
||||
except Exception as e:
|
||||
self.alert_manager.show_error("update_download_error", parent=parent)
|
||||
return False
|
||||
|
||||
def show_update_dialog(self, releases: List[Dict], current_version: str, parent=None) -> bool:
|
||||
"""
|
||||
Affiche une boîte de dialogue avec options Mettre à jour et Détails via l'alert_manager
|
||||
"""
|
||||
latest_release = releases[0]
|
||||
message = self.language_manager.get_text("update_found").replace("{latest_tag}", latest_release["tag_name"])
|
||||
|
||||
choice = self.alert_manager.show_choice_with_details(
|
||||
message,
|
||||
parent=parent,
|
||||
details_callback=lambda: self.show_details_dialog(releases, current_version, parent)
|
||||
)
|
||||
|
||||
return choice
|
||||
|
||||
def show_details_dialog(self, releases: List[Dict], current_version: str, parent=None) -> None:
|
||||
"""
|
||||
Affiche tous les changelogs des versions supérieures à la version actuelle
|
||||
"""
|
||||
dialog = QDialog(parent)
|
||||
dialog.setWindowTitle(self.language_manager.get_text("update_details"))
|
||||
dialog.setModal(True)
|
||||
dialog.resize(600, 500)
|
||||
|
||||
layout = QVBoxLayout(dialog)
|
||||
|
||||
# Zone de texte pour afficher les changelogs
|
||||
text_edit = QTextEdit()
|
||||
text_edit.setReadOnly(True)
|
||||
|
||||
# Filtrer et trier les releases supérieures à la version actuelle
|
||||
newer_releases = []
|
||||
for release in releases:
|
||||
if version.parse(release["tag_name"]) > version.parse(current_version):
|
||||
newer_releases.append(release)
|
||||
|
||||
# Trier par version décroissante (plus récente en premier)
|
||||
newer_releases.sort(key=lambda x: version.parse(x["tag_name"]), reverse=True)
|
||||
|
||||
# Construire le texte des changelogs
|
||||
changelog_text = ""
|
||||
for release in newer_releases:
|
||||
changelog_text += f"## {self.language_manager.get_text('version')} {release['tag_name']} :\n\n"
|
||||
body = release['body'].replace('\n','\n### ')
|
||||
changelog_text += f"### {body}"
|
||||
changelog_text += "\n\n"
|
||||
if release != newer_releases[-1]:
|
||||
changelog_text += "---\n\n"
|
||||
|
||||
text_edit.setAcceptRichText(True)
|
||||
text_edit.setMarkdown(changelog_text)
|
||||
layout.addWidget(text_edit)
|
||||
|
||||
# Bouton Fermer
|
||||
close_button = QPushButton(self.language_manager.get_text("close"))
|
||||
close_button.clicked.connect(dialog.close)
|
||||
layout.addWidget(close_button)
|
||||
|
||||
dialog.exec()
|
||||
277
app/ui/main_window.py
Normal file
277
app/ui/main_window.py
Normal file
@ -0,0 +1,277 @@
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel
|
||||
from PyQt6.QtGui import QResizeEvent, QCloseEvent
|
||||
from PyQt6.QtCore import QSize, QEvent
|
||||
from app.core.main_manager import MainManager, NotificationType
|
||||
from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition, BorderSide, TabSide, TextPosition
|
||||
from app.ui.windows.settings_window import SettingsWindow
|
||||
from app.ui.windows.suggestion_window import SuggestionWindow
|
||||
from app.ui.windows.activation_window import ActivationWindow
|
||||
import app.utils.paths as paths, shutil
|
||||
from typing import Optional
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.main_manager: MainManager = MainManager.get_instance()
|
||||
|
||||
self.language_manager = self.main_manager.get_language_manager()
|
||||
self.theme_manager = self.main_manager.get_theme_manager()
|
||||
self.settings_manager = self.main_manager.get_settings_manager()
|
||||
self.observer_manager = self.main_manager.get_observer_manager()
|
||||
self.observer_manager.subscribe(NotificationType.THEME, self.update_theme)
|
||||
self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language)
|
||||
self.is_maximizing: bool = False
|
||||
|
||||
# Initialiser les attributs de taille AVANT setup_ui
|
||||
app: Optional[QApplication] = QApplication.instance()
|
||||
size: QSize = app.primaryScreen().size()
|
||||
self.settings_manager.minScreenSize = min(size.height(), size.width())
|
||||
|
||||
# Initialiser les tailles par défaut
|
||||
window_size: dict = self.settings_manager.get_window_size()
|
||||
self.current_size: QSize = QSize(window_size["width"], window_size["height"])
|
||||
self.previous_size: QSize = QSize(window_size["width"], window_size["height"])
|
||||
|
||||
# Configuration des tailles de police de référence
|
||||
self.base_width = 1200 # Largeur de référence (taille par défaut)
|
||||
self.base_height = 700 # Hauteur de référence (taille par défaut)
|
||||
self.base_tab_height = 70 # Hauteur de base du tab menu
|
||||
|
||||
# Cache pour stocker les font-sizes de base de chaque widget
|
||||
self._base_font_sizes = {}
|
||||
self._font_sizes_extracted = False # Flag pour savoir si on a déjà extrait les tailles
|
||||
|
||||
# UI elements
|
||||
self.side_menu: TabsWidget
|
||||
self.settings_window: SettingsWindow
|
||||
self.suggestion_window: SuggestionWindow
|
||||
|
||||
self.setMinimumSize(600, 450)
|
||||
|
||||
# Initialiser l'UI immédiatement (sera fait pendant le splash)
|
||||
self.setup_ui()
|
||||
|
||||
# Différer l'application des paramètres de fenêtre jusqu'à l'affichage réel
|
||||
self._window_state_applied = False
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Applique les paramètres de fenêtre lors du premier affichage"""
|
||||
super().showEvent(event)
|
||||
if not self._window_state_applied:
|
||||
self.apply_saved_window_state()
|
||||
self._window_state_applied = True
|
||||
|
||||
def apply_saved_window_state(self) -> None:
|
||||
"""Apply saved window size and maximized state"""
|
||||
window_size: dict = self.settings_manager.get_window_size()
|
||||
self.current_size = QSize(window_size["width"], window_size["height"])
|
||||
self.previous_size = QSize(window_size["width"], window_size["height"])
|
||||
self.resize(self.current_size)
|
||||
if self.settings_manager.get_maximized():
|
||||
self.is_maximizing = True
|
||||
self.showMaximized()
|
||||
|
||||
def changeEvent(self, event: QEvent) -> None:
|
||||
"""Handle window state changes"""
|
||||
super().changeEvent(event)
|
||||
if event.type() == event.Type.WindowStateChange:
|
||||
if self.isMaximized():
|
||||
# On vient de maximiser
|
||||
self.is_maximizing = False
|
||||
else:
|
||||
# On vient de dé-maximiser, restaurer la taille précédente
|
||||
if hasattr(self, 'previous_size'):
|
||||
self.current_size = self.previous_size
|
||||
self.settings_manager.set_maximized(self.isMaximized())
|
||||
|
||||
def resizeEvent(self, a0: QResizeEvent) -> None:
|
||||
# Ne pas sauvegarder la taille si on est en train de maximiser
|
||||
if not self.isMaximized() and not self.is_maximizing:
|
||||
self.previous_size = self.current_size
|
||||
self.current_size = self.size()
|
||||
|
||||
# Ajuster dynamiquement les font-sizes avec un ratio
|
||||
self.adjust_all_font_sizes()
|
||||
|
||||
def adjust_all_font_sizes(self):
|
||||
"""Ajuste dynamiquement les font-sizes de tous les éléments avec un ratio proportionnel"""
|
||||
# Calculer le ratio basé sur la largeur ET la hauteur actuelle
|
||||
current_width = self.width()
|
||||
current_height = self.height()
|
||||
|
||||
# Calculer les ratios séparément
|
||||
width_ratio = current_width / self.base_width
|
||||
height_ratio = current_height / self.base_height
|
||||
|
||||
# Utiliser la moyenne des deux ratios pour un scaling plus naturel
|
||||
# Ou utiliser le minimum pour éviter le débordement
|
||||
ratio = min(width_ratio,height_ratio) * 1.5
|
||||
|
||||
# Limiter le ratio pour éviter des tailles extrêmes
|
||||
ratio = max(0.5, min(ratio, 2.0)) # Entre 50% et 200%
|
||||
|
||||
# Récupérer tous les widgets des tabs
|
||||
all_widgets = []
|
||||
if hasattr(self, 'side_menu'):
|
||||
all_widgets = self.side_menu.widgets
|
||||
|
||||
# Extraire les tailles de base une seule fois
|
||||
if not self._font_sizes_extracted:
|
||||
self._extract_base_font_sizes(all_widgets)
|
||||
self._font_sizes_extracted = True
|
||||
|
||||
# Parcourir tous les widgets et ajuster leurs tailles
|
||||
for widget in all_widgets:
|
||||
if widget:
|
||||
self._adjust_widget_font_sizes(widget, ratio)
|
||||
|
||||
def _extract_base_font_sizes(self, widgets):
|
||||
"""Extrait les tailles de police de base de tous les widgets une seule fois"""
|
||||
from PyQt6.QtWidgets import QPushButton, QLineEdit, QTextEdit, QComboBox
|
||||
|
||||
widget_types = [QLabel, QPushButton, QLineEdit, QTextEdit, QComboBox]
|
||||
# Extraire les tailles des boutons d'onglets du side menu
|
||||
if hasattr(self, 'side_menu') and hasattr(self.side_menu, 'buttons'):
|
||||
for button in self.side_menu.buttons:
|
||||
if button:
|
||||
widget_id = id(button)
|
||||
current_style = button.styleSheet()
|
||||
base_size = self._extract_font_size_from_style(current_style)
|
||||
if base_size is None:
|
||||
base_size = 14 # Taille par défaut
|
||||
self._base_font_sizes[widget_id] = base_size
|
||||
|
||||
for widget in widgets:
|
||||
if not widget:
|
||||
continue
|
||||
|
||||
for widget_type in widget_types:
|
||||
for child in widget.findChildren(widget_type):
|
||||
widget_id = id(child)
|
||||
|
||||
# Ignorer les widgets avec un objectName (généralement stylisés spécifiquement)
|
||||
if child.objectName() != "":
|
||||
continue
|
||||
|
||||
# Extraire la taille de police depuis le stylesheet
|
||||
current_style = child.styleSheet()
|
||||
base_size = self._extract_font_size_from_style(current_style)
|
||||
|
||||
# Si pas trouvé dans le style, utiliser la taille par défaut
|
||||
if base_size is None:
|
||||
base_size = 14 # Taille par défaut
|
||||
|
||||
# Stocker la taille de base
|
||||
self._base_font_sizes[widget_id] = base_size
|
||||
|
||||
def _extract_font_size_from_style(self, style: str) -> Optional[int]:
|
||||
"""Extrait la taille de police depuis un stylesheet"""
|
||||
import re
|
||||
|
||||
# Chercher "font-size: XXpx"
|
||||
match = re.search(r'font-size:\s*(\d+)px', style)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
|
||||
return None
|
||||
|
||||
def _adjust_widget_font_sizes(self, widget, ratio):
|
||||
"""Ajuste les font-sizes de tous les éléments d'un widget avec un ratio proportionnel"""
|
||||
from PyQt6.QtWidgets import QPushButton, QLineEdit, QTextEdit, QComboBox
|
||||
import re
|
||||
|
||||
# Ajuster les boutons d'onglets du side menu
|
||||
if hasattr(self, 'side_menu') and hasattr(self.side_menu, 'buttons'):
|
||||
for button in self.side_menu.buttons:
|
||||
if button:
|
||||
widget_id = id(button)
|
||||
if widget_id in self._base_font_sizes:
|
||||
base_size = self._base_font_sizes[widget_id]
|
||||
new_size = max(8, int(base_size * ratio))
|
||||
current_style = button.styleSheet()
|
||||
style_without_font = re.sub(r'font-size:\s*\d+px;?', '', current_style)
|
||||
style_without_font = re.sub(r';+', ';', style_without_font)
|
||||
style_without_font = style_without_font.strip()
|
||||
if style_without_font and not style_without_font.endswith(';'):
|
||||
style_without_font += ';'
|
||||
new_style = f"{style_without_font} font-size: {new_size}px;"
|
||||
button.setStyleSheet(new_style)
|
||||
|
||||
widget_types = [QLabel, QPushButton, QLineEdit, QTextEdit, QComboBox]
|
||||
|
||||
for widget_type in widget_types:
|
||||
for child in widget.findChildren(widget_type):
|
||||
widget_id = id(child)
|
||||
|
||||
# Récupérer la taille de base
|
||||
if widget_id not in self._base_font_sizes:
|
||||
continue # Pas de taille de base, ignorer
|
||||
|
||||
base_size = self._base_font_sizes[widget_id]
|
||||
|
||||
# Calculer la nouvelle taille avec le ratio
|
||||
new_size = max(8, int(base_size * ratio)) # Minimum 8px
|
||||
|
||||
# Appliquer le style en préservant les autres propriétés
|
||||
current_style = child.styleSheet()
|
||||
|
||||
# Retirer l'ancienne font-size
|
||||
style_without_font = re.sub(r'font-size:\s*\d+px;?', '', current_style)
|
||||
|
||||
# Nettoyer les points-virgules multiples
|
||||
style_without_font = re.sub(r';+', ';', style_without_font)
|
||||
style_without_font = style_without_font.strip()
|
||||
|
||||
# Ajouter la nouvelle font-size
|
||||
if style_without_font and not style_without_font.endswith(';'):
|
||||
style_without_font += ';'
|
||||
|
||||
new_style = f"{style_without_font} font-size: {new_size}px;"
|
||||
child.setStyleSheet(new_style)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
"""Handle application close event"""
|
||||
super().closeEvent(event)
|
||||
# si la difference de taille est plus grande que 10 pixels, enregistrer previoussize
|
||||
if abs(self.current_size.width() - self.previous_size.width()) > 10 or abs(self.current_size.height() - self.previous_size.height()) > 10:
|
||||
self.current_size = self.previous_size
|
||||
self.settings_manager.set_window_size(
|
||||
self.current_size.width(),
|
||||
self.current_size.height()
|
||||
)
|
||||
try:
|
||||
shutil.rmtree(paths.get_user_temp(self.settings_manager.get_config("app_name")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def setup_ui(self) -> None:
|
||||
|
||||
self.side_menu = TabsWidget(self, MenuDirection.HORIZONTAL, 70, None, 10, BorderSide.BOTTOM, TabSide.TOP)
|
||||
|
||||
self.suggestion_window = SuggestionWindow(self)
|
||||
self.side_menu.add_widget(self.suggestion_window, self.language_manager.get_text("tab_suggestions"), paths.get_asset_svg_path("suggestion"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM)
|
||||
|
||||
self.settings_window = SettingsWindow(self)
|
||||
self.side_menu.add_widget(self.settings_window, self.language_manager.get_text("tab_settings"), paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM)
|
||||
|
||||
# Ajouter la tab d'activation uniquement si le système de licence est activé
|
||||
if self.settings_manager.get_config("enable_licensing"):
|
||||
self.activation_window = ActivationWindow(self)
|
||||
self.side_menu.add_widget(self.activation_window, self.language_manager.get_text("tab_licensing"), paths.get_asset_svg_path("license"), position=ButtonPosition.END, text_position=TextPosition.BOTTOM)
|
||||
|
||||
self.setCentralWidget(self.side_menu)
|
||||
|
||||
def get_tab_widget(self):
|
||||
"""Retourne le widget TabsWidget pour permettre le changement d'onglet"""
|
||||
return self.side_menu
|
||||
|
||||
def update_theme(self) -> None:
|
||||
self.setStyleSheet(self.theme_manager.get_sheet())
|
||||
|
||||
def update_language(self) -> None:
|
||||
# Mettre à jour les textes des onglets
|
||||
self.side_menu.update_button_text(0, self.language_manager.get_text("tab_suggestions"))
|
||||
self.side_menu.update_button_text(1, self.language_manager.get_text("tab_settings"))
|
||||
if self.settings_manager.get_config("enable_licensing"):
|
||||
self.side_menu.update_button_text(2, self.language_manager.get_text("tab_licensing"))
|
||||
20
app/ui/widgets/loading_bar.py
Normal file
20
app/ui/widgets/loading_bar.py
Normal file
@ -0,0 +1,20 @@
|
||||
from PyQt6.QtWidgets import QProgressBar, QWidget, QVBoxLayout, QLabel
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
class LoadingBar(QWidget):
|
||||
def __init__(self, label_text: str = "", parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.label = QLabel(label_text, self)
|
||||
self.progress = QProgressBar(self)
|
||||
self.progress.setMinimum(0)
|
||||
self.progress.setMaximum(100)
|
||||
layout.addWidget(self.label)
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
def set_label(self, text: str) -> None:
|
||||
self.label.setText(text)
|
||||
|
||||
def set_progress(self, value: int) -> None:
|
||||
self.progress.setValue(value)
|
||||
66
app/ui/widgets/loading_spinner.py
Normal file
66
app/ui/widgets/loading_spinner.py
Normal file
@ -0,0 +1,66 @@
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtWidgets import QLabel
|
||||
from PyQt6.QtGui import QPainter, QPen, QColor
|
||||
from app.core.main_manager import MainManager
|
||||
import math
|
||||
|
||||
class LoadingSpinner(QLabel):
|
||||
def __init__(self, size=40, parent=None):
|
||||
super().__init__(parent)
|
||||
self.size = size
|
||||
self.angle = 0
|
||||
self.setFixedSize(size, size)
|
||||
|
||||
# Timer pour l'animation
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self.rotate)
|
||||
self.timer.start(50) # 50ms = rotation fluide
|
||||
|
||||
def rotate(self):
|
||||
self.angle = (self.angle + 10) % 360
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Obtenir la couleur du thème
|
||||
main_manager = MainManager.get_instance()
|
||||
theme = main_manager.get_theme_manager().get_theme()
|
||||
color = theme.get_color("background_secondary_color")
|
||||
|
||||
# Dessiner le cercle de chargement
|
||||
rect = self.rect()
|
||||
center_x, center_y = rect.width() // 2, rect.height() // 2
|
||||
radius = min(center_x, center_y) - 5
|
||||
|
||||
painter.translate(center_x, center_y)
|
||||
painter.rotate(self.angle)
|
||||
|
||||
# Dessiner les segments du spinner
|
||||
pen = QPen()
|
||||
pen.setWidth(3)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
|
||||
for i in range(8):
|
||||
alpha = 255 - (i * 25) # Dégradé d'opacité
|
||||
pen.setColor(self.hex_to_qcolor(color, alpha))
|
||||
painter.setPen(pen)
|
||||
|
||||
angle = i * 45
|
||||
start_x = radius * 0.7 * math.cos(math.radians(angle))
|
||||
start_y = radius * 0.7 * math.sin(math.radians(angle))
|
||||
end_x = radius * math.cos(math.radians(angle))
|
||||
end_y = radius * math.sin(math.radians(angle))
|
||||
|
||||
painter.drawLine(int(start_x), int(start_y), int(end_x), int(end_y))
|
||||
|
||||
def hex_to_qcolor(self, hex_color, alpha=255):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r = int(hex_color[0:2], 16)
|
||||
g = int(hex_color[2:4], 16)
|
||||
b = int(hex_color[4:6], 16)
|
||||
return QColor(r, g, b, alpha)
|
||||
|
||||
def stop(self):
|
||||
self.timer.stop()
|
||||
760
app/ui/widgets/tabs_widget.py
Normal file
760
app/ui/widgets/tabs_widget.py
Normal file
@ -0,0 +1,760 @@
|
||||
from PyQt6.QtWidgets import QLayout, QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QStackedWidget, QSizePolicy, QSpacerItem, QLabel
|
||||
from PyQt6.QtGui import QIcon
|
||||
from PyQt6.QtCore import QSize, Qt
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import app.utils.paths as paths
|
||||
from app.core.main_manager import MainManager, NotificationType
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
class MenuDirection(Enum):
|
||||
HORIZONTAL = 0 # Barre en haut ou en bas
|
||||
VERTICAL = 1 # Barre à gauche ou à droite
|
||||
|
||||
class ButtonPosition(Enum):
|
||||
START = 0 # Au début (aligné à gauche/haut)
|
||||
END = 1 # À la fin (aligné à droite/bas)
|
||||
CENTER = 2 # Au centre
|
||||
AFTER = 3 # Après un bouton spécifique
|
||||
|
||||
class BorderSide(Enum):
|
||||
LEFT = "left" # Bord gauche
|
||||
RIGHT = "right" # Bord droit
|
||||
TOP = "top" # Bord supérieur
|
||||
BOTTOM = "bottom" # Bord inférieur
|
||||
NONE = None
|
||||
|
||||
class TextPosition(Enum):
|
||||
LEFT = 0 # Texte à gauche de l'icône
|
||||
RIGHT = 1 # Texte à droite de l'icône
|
||||
TOP = 2 # Texte au-dessus de l'icône
|
||||
BOTTOM = 3 # Texte en dessous de l'icône
|
||||
|
||||
class TabSide(Enum):
|
||||
LEFT = 0 # Barre à gauche (pour VERTICAL)
|
||||
RIGHT = 1 # Barre à droite (pour VERTICAL)
|
||||
TOP = 0 # Barre en haut (pour HORIZONTAL)
|
||||
BOTTOM = 1 # Barre en bas (pour HORIZONTAL)
|
||||
|
||||
class TabsWidget(QWidget):
|
||||
def __init__(self, parent=None, direction=MenuDirection.VERTICAL, menu_width=80, onTabChange=None, spacing=10, border_side=BorderSide.LEFT, tab_side=None, text_position=TextPosition.BOTTOM):
|
||||
super().__init__(parent)
|
||||
self.main_manager = MainManager.get_instance()
|
||||
self.theme_manager = self.main_manager.get_theme_manager()
|
||||
self.observer_manager = self.main_manager.get_observer_manager()
|
||||
self.observer_manager.subscribe(NotificationType.THEME, self.set_theme)
|
||||
self.direction = direction
|
||||
self.menu_width = menu_width
|
||||
self.onTabChange = onTabChange
|
||||
self.text_position = text_position # Position du texte par rapport à l'icône
|
||||
|
||||
# Gérer border_side comme une liste ou un seul élément
|
||||
if isinstance(border_side, list):
|
||||
self.border_sides = border_side
|
||||
elif border_side is not None:
|
||||
self.border_sides = [border_side]
|
||||
else:
|
||||
self.border_sides = []
|
||||
|
||||
# Déterminer le côté de la barre d'onglets
|
||||
if tab_side is None:
|
||||
self.tab_side = TabSide.LEFT if direction == MenuDirection.VERTICAL else TabSide.TOP
|
||||
else:
|
||||
self.tab_side = tab_side
|
||||
|
||||
self.buttons = []
|
||||
self.widgets = []
|
||||
self.button_positions = []
|
||||
self.button_text_positions = [] # Individual text positions for each button
|
||||
self._icon_cache = {}
|
||||
self._original_icon_paths = []
|
||||
self._square_buttons = []
|
||||
# Track alignment zones
|
||||
self.start_buttons = []
|
||||
self.center_buttons = []
|
||||
self.end_buttons = []
|
||||
# Icon Colors
|
||||
self.selected_icon_color = self.theme_manager.current_theme.get_color("icon_selected_color")
|
||||
self.unselected_icon_color = self.theme_manager.current_theme.get_color("icon_unselected_color")
|
||||
self.selected_border_icon_color = self.theme_manager.current_theme.get_color("icon_selected_border_color")
|
||||
self.hover_icon_color = self.theme_manager.current_theme.get_color("icon_hover_color")
|
||||
|
||||
# Spacer items for alignment
|
||||
self.left_spacer = None
|
||||
self.center_spacer = None
|
||||
self.right_spacer = None
|
||||
self.spacing = spacing
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the main layout based on direction and tab_side"""
|
||||
if self.direction == MenuDirection.HORIZONTAL:
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.button_layout = QHBoxLayout()
|
||||
else: # VERTICAL
|
||||
self.main_layout = QHBoxLayout(self)
|
||||
self.button_layout = QVBoxLayout()
|
||||
|
||||
# Remove all spacing and margins
|
||||
self.main_layout.setSpacing(0)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.button_layout.setSpacing(self.spacing)
|
||||
self.button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.stacked_widget = QStackedWidget()
|
||||
# Create button container widget
|
||||
self.button_container = QWidget()
|
||||
self.button_container.setObjectName("tab_bar")
|
||||
self.button_container.setLayout(self.button_layout)
|
||||
|
||||
# Remove all margins from button container
|
||||
self.button_container.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Set minimum size for button container to prevent shrinking
|
||||
if self.direction == MenuDirection.VERTICAL:
|
||||
self.button_container.setMaximumWidth(self.menu_width)
|
||||
self.button_container.setMinimumWidth(self.menu_width)
|
||||
self.button_container.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
|
||||
else:
|
||||
self.button_container.setMinimumHeight(self.menu_width)
|
||||
self.button_container.setMaximumHeight(self.menu_width)
|
||||
self.button_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
# Initialize spacers for alignment zones
|
||||
self._setup_alignment_zones()
|
||||
|
||||
# Add widgets to main layout based on direction and tab_side
|
||||
if self.tab_side == TabSide.LEFT:
|
||||
self.main_layout.addWidget(self.button_container)
|
||||
self.main_layout.addWidget(self.stacked_widget)
|
||||
else: # TabSide.RIGHT
|
||||
self.main_layout.addWidget(self.stacked_widget)
|
||||
self.main_layout.addWidget(self.button_container)
|
||||
|
||||
self.setLayout(self.main_layout)
|
||||
|
||||
def _setup_alignment_zones(self):
|
||||
"""Setup spacers to create alignment zones"""
|
||||
# Create spacers
|
||||
if self.direction == MenuDirection.HORIZONTAL:
|
||||
self.left_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
self.center_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
else:
|
||||
self.left_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
|
||||
self.center_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
|
||||
|
||||
# Add spacers to layout (no right spacer so END buttons stick to edge)
|
||||
self.button_layout.addSpacerItem(self.left_spacer) # Before center
|
||||
self.button_layout.addSpacerItem(self.center_spacer) # After center
|
||||
# Removed right_spacer so END buttons are at the edge
|
||||
|
||||
# Add a widget on the menu, just the widget, with position
|
||||
def add_random_widget(self, widget, position=ButtonPosition.END):
|
||||
"""Add a widget directly to the menu at the specified position"""
|
||||
if isinstance(widget, QLayout):
|
||||
# If it's a layout, wrap it in a container widget
|
||||
container_widget = QWidget()
|
||||
container_widget.setLayout(widget)
|
||||
container_widget.setContentsMargins(0, 0, 0, 0)
|
||||
widget = container_widget
|
||||
self._insert_widget_with_alignment(widget, position)
|
||||
return widget
|
||||
|
||||
def add_random_layout(self, layout, position=ButtonPosition.END):
|
||||
"""Add a layout at the specified position in the button layout"""
|
||||
# Create a container widget for the layout
|
||||
container_widget = QWidget()
|
||||
container_widget.setLayout(layout)
|
||||
container_widget.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Insert the container widget at the specified position
|
||||
self._insert_widget_with_alignment(container_widget, position)
|
||||
return container_widget
|
||||
|
||||
def _insert_widget_with_alignment(self, widget, position):
|
||||
"""Insert any widget at the specified position with proper visual alignment"""
|
||||
if position == ButtonPosition.START:
|
||||
# Insert at the beginning (before left spacer)
|
||||
self.button_layout.insertWidget(0, widget)
|
||||
|
||||
elif position == ButtonPosition.CENTER:
|
||||
# Insert in center zone (after left spacer, before center spacer)
|
||||
center_start_index = self._get_spacer_index(self.left_spacer) + 1
|
||||
insert_index = center_start_index + len(self.center_buttons)
|
||||
self.button_layout.insertWidget(insert_index, widget)
|
||||
|
||||
elif position == ButtonPosition.END:
|
||||
# Insert in end zone (after center spacer, at the end)
|
||||
end_start_index = self._get_spacer_index(self.center_spacer) + 1
|
||||
insert_index = end_start_index + len(self.end_buttons)
|
||||
self.button_layout.insertWidget(insert_index, widget)
|
||||
|
||||
def add_widget(self, widget, button_text, icon_path=None, position=ButtonPosition.END, after_button_index=None, text_position=None):
|
||||
"""Add a widget with its corresponding button at specified position"""
|
||||
# Use provided text_position or default to widget's text_position
|
||||
btn_text_position = text_position if text_position is not None else self.text_position
|
||||
|
||||
# Create button container with custom layout
|
||||
button_container = self._create_button_with_layout(button_text, icon_path, btn_text_position)
|
||||
|
||||
self._original_icon_paths.append(icon_path)
|
||||
|
||||
button_container.setCheckable(True)
|
||||
|
||||
self.button_text_positions.append(btn_text_position)
|
||||
|
||||
# Make button square with specified ratio
|
||||
self._style_square_button(button_container)
|
||||
|
||||
# Configurer le widget pour qu'il soit responsive
|
||||
widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
# Add to collections first
|
||||
widget_index = len(self.widgets)
|
||||
self.buttons.append(button_container)
|
||||
self.widgets.append(widget)
|
||||
self.button_positions.append(position)
|
||||
|
||||
# Connect button to switch function
|
||||
button_container.clicked.connect(lambda checked, idx=widget_index: self.switch_to_tab(idx))
|
||||
|
||||
# Add widget to stacked widget
|
||||
self.stacked_widget.addWidget(widget)
|
||||
|
||||
# Insert button at specified position with proper alignment
|
||||
self._insert_button_with_alignment(button_container, position, after_button_index)
|
||||
|
||||
# Select first tab by default
|
||||
if len(self.buttons) == 1:
|
||||
self.switch_to_tab(0)
|
||||
|
||||
return widget_index
|
||||
|
||||
def _create_button_with_layout(self, text: str, icon_path: str, text_position: TextPosition) -> QPushButton:
|
||||
"""Create a button with custom layout for icon and text positioning"""
|
||||
button = QPushButton()
|
||||
|
||||
has_icon = icon_path is not None and icon_path != ""
|
||||
has_text = text is not None and text.strip() != ""
|
||||
|
||||
# Create icon label only if there's an icon
|
||||
icon_label = None
|
||||
if has_icon:
|
||||
icon_label = QLabel()
|
||||
icon_label.setObjectName("icon_label")
|
||||
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter)
|
||||
colored_icon = self.apply_color_to_svg_icon(icon_path, self.unselected_icon_color)
|
||||
pixmap = colored_icon.pixmap(QSize(32, 32)) # Taille par défaut, sera ajustée
|
||||
icon_label.setPixmap(pixmap)
|
||||
|
||||
# Create text label only if there's text
|
||||
text_label = None
|
||||
if has_text:
|
||||
text_label = QLabel(text)
|
||||
text_label.setObjectName("text_label")
|
||||
text_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter)
|
||||
text_label.setWordWrap(True)
|
||||
|
||||
# Create layout based on what we have and text position
|
||||
if has_icon and has_text:
|
||||
# Both icon and text
|
||||
if text_position == TextPosition.LEFT:
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
elif text_position == TextPosition.RIGHT:
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
elif text_position == TextPosition.TOP:
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
else: # BOTTOM (default)
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
elif has_icon:
|
||||
# Only icon
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
elif has_text:
|
||||
# Only text
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
else:
|
||||
# Neither icon nor text - empty button
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(2)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
button.setLayout(layout)
|
||||
|
||||
# Store references to labels for later updates (can be None)
|
||||
button.icon_label = icon_label
|
||||
button.text_label = text_label
|
||||
|
||||
return button
|
||||
|
||||
def _apply_text_position(self, button: QPushButton, text_position: TextPosition):
|
||||
"""Apply text position to button by recreating its layout"""
|
||||
# Get existing labels
|
||||
if not hasattr(button, 'icon_label') or not hasattr(button, 'text_label'):
|
||||
return
|
||||
|
||||
icon_label = button.icon_label
|
||||
text_label = button.text_label
|
||||
|
||||
has_icon = icon_label is not None
|
||||
has_text = text_label is not None
|
||||
|
||||
# Remove old layout
|
||||
old_layout = button.layout()
|
||||
if old_layout:
|
||||
# Remove widgets from layout
|
||||
while old_layout.count():
|
||||
item = old_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().setParent(None)
|
||||
QWidget().setLayout(old_layout) # Delete old layout
|
||||
|
||||
# Create new layout based on what we have and text position
|
||||
if has_icon and has_text:
|
||||
if text_position == TextPosition.LEFT:
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
elif text_position == TextPosition.RIGHT:
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
elif text_position == TextPosition.TOP:
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
else: # BOTTOM (default)
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
elif has_icon:
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
elif has_text:
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
else:
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(2)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
button.setLayout(layout)
|
||||
|
||||
def _style_square_button(self, button):
|
||||
# Set size policy
|
||||
button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
|
||||
# Create border style based on border_sides setting
|
||||
border_style = self._get_border_style()
|
||||
button.setStyleSheet(border_style)
|
||||
|
||||
# Install event filter for hover detection
|
||||
button.installEventFilter(self)
|
||||
|
||||
# Calculate initial size (will be updated in resizeEvent)
|
||||
self._update_button_size(button)
|
||||
|
||||
# Store reference for resize updates
|
||||
self._square_buttons.append(button)
|
||||
|
||||
def eventFilter(self, obj : QPushButton, event):
|
||||
"""Handle hover events for buttons"""
|
||||
if obj in self.buttons:
|
||||
if event.type() == event.Type.Enter:
|
||||
# Mouse entered button
|
||||
button_index = self.buttons.index(obj)
|
||||
if hasattr(obj, 'icon_label') and button_index < len(self._original_icon_paths):
|
||||
icon_path = self._original_icon_paths[button_index]
|
||||
if icon_path:
|
||||
colored_icon = self.apply_color_to_svg_icon(icon_path, self.hover_icon_color)
|
||||
current_size = obj.icon_label.size()
|
||||
pixmap = colored_icon.pixmap(current_size)
|
||||
obj.icon_label.setPixmap(pixmap)
|
||||
elif event.type() == event.Type.Leave:
|
||||
# Mouse left button
|
||||
button_index = self.buttons.index(obj)
|
||||
if hasattr(obj, 'icon_label') and button_index < len(self._original_icon_paths):
|
||||
icon_path = self._original_icon_paths[button_index]
|
||||
if icon_path:
|
||||
color = self.unselected_icon_color if not obj.isChecked() else self.selected_icon_color
|
||||
colored_icon = self.apply_color_to_svg_icon(icon_path, color)
|
||||
current_size = obj.icon_label.size()
|
||||
pixmap = colored_icon.pixmap(current_size)
|
||||
obj.icon_label.setPixmap(pixmap)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _get_border_style(self):
|
||||
"""Generate CSS border style based on border_sides setting"""
|
||||
if not self.border_sides or BorderSide.NONE in self.border_sides:
|
||||
return f"""
|
||||
QPushButton {{
|
||||
border-radius: 0px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}}
|
||||
QPushButton[selected="true"] {{
|
||||
border-radius: 0px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
# Construire le style CSS pour chaque côté de bordure
|
||||
border_declarations = []
|
||||
selected_border_declarations = []
|
||||
|
||||
for border_side in self.border_sides:
|
||||
if border_side != BorderSide.NONE and border_side is not None:
|
||||
border_declarations.append(f"border-{border_side.value}: 3px solid transparent")
|
||||
selected_border_declarations.append(f"border-{border_side.value}: 3px solid {self.selected_border_icon_color}")
|
||||
|
||||
border_style = "; ".join(border_declarations)
|
||||
selected_border_style = "; ".join(selected_border_declarations)
|
||||
|
||||
return f"""
|
||||
QPushButton {{
|
||||
border-radius: 0px;
|
||||
background-color: transparent;
|
||||
{border_style};
|
||||
}}
|
||||
QPushButton[selected="true"] {{
|
||||
border-radius: 0px;
|
||||
background-color: transparent;
|
||||
{selected_border_style};
|
||||
}}
|
||||
"""
|
||||
|
||||
def _update_button_size(self, button):
|
||||
"""Update button size - will be recalculated globally"""
|
||||
# This method now triggers a global recalculation
|
||||
self._recalculate_all_buttons_size()
|
||||
|
||||
def _recalculate_all_buttons_size(self):
|
||||
"""Recalculate size for all buttons to ensure uniform sizing"""
|
||||
if not self.buttons:
|
||||
return
|
||||
|
||||
max_secondary_size = 0
|
||||
|
||||
# First pass: calculate the maximum secondary dimension needed
|
||||
for i, button in enumerate(self.buttons):
|
||||
has_icon = button.icon_label is not None
|
||||
has_text = button.text_label is not None
|
||||
|
||||
if has_icon and has_text:
|
||||
text_pos = self.button_text_positions[i] if i < len(self.button_text_positions) else self.text_position
|
||||
if self.direction == MenuDirection.VERTICAL:
|
||||
# Vertical menu: calculate needed height
|
||||
if text_pos in [TextPosition.TOP, TextPosition.BOTTOM]:
|
||||
# Vertical layout: need square
|
||||
max_secondary_size = max(max_secondary_size, self.menu_width)
|
||||
else:
|
||||
# Horizontal layout: need less height but ensure text fits
|
||||
if has_text and button.text_label:
|
||||
text_height = button.text_label.sizeHint().height() + 20
|
||||
icon_height = int(self.menu_width * 0.4) + 10
|
||||
needed_height = max(text_height, icon_height, int(self.menu_width * 0.6))
|
||||
max_secondary_size = max(max_secondary_size, needed_height)
|
||||
else:
|
||||
# Horizontal menu: calculate needed width
|
||||
if text_pos in [TextPosition.LEFT, TextPosition.RIGHT]:
|
||||
# Horizontal layout: need square
|
||||
max_secondary_size = max(max_secondary_size, self.menu_width)
|
||||
else:
|
||||
# Vertical layout: need less width but ensure text fits
|
||||
if has_text and button.text_label:
|
||||
text_width = button.text_label.sizeHint().width() + 20
|
||||
icon_width = int(self.menu_width * 0.4) + 10
|
||||
needed_width = max(text_width, icon_width, int(self.menu_width * 0.6))
|
||||
max_secondary_size = max(max_secondary_size, needed_width)
|
||||
elif has_icon or has_text:
|
||||
# Only icon or only text
|
||||
if has_text and button.text_label:
|
||||
if self.direction == MenuDirection.VERTICAL:
|
||||
text_height = button.text_label.sizeHint().height() + 20
|
||||
max_secondary_size = max(max_secondary_size, text_height, int(self.menu_width * 0.6))
|
||||
else:
|
||||
text_width = button.text_label.sizeHint().width() + 20
|
||||
max_secondary_size = max(max_secondary_size, text_width, int(self.menu_width * 0.6))
|
||||
else:
|
||||
max_secondary_size = max(max_secondary_size, int(self.menu_width * 0.6))
|
||||
else:
|
||||
# Empty button
|
||||
max_secondary_size = max(max_secondary_size, int(self.menu_width * 0.4))
|
||||
|
||||
# Ensure minimum size
|
||||
max_secondary_size = max(max_secondary_size, int(self.menu_width * 0.6))
|
||||
|
||||
# Second pass: apply uniform size to all buttons
|
||||
for i, button in enumerate(self.buttons):
|
||||
has_icon = button.icon_label is not None
|
||||
|
||||
if self.direction == MenuDirection.VERTICAL:
|
||||
# Vertical: width = menu_width, height = max calculated
|
||||
button_width = self.menu_width
|
||||
button_height = max_secondary_size
|
||||
else:
|
||||
# Horizontal: height = menu_width, width = max calculated
|
||||
button_height = self.menu_width
|
||||
button_width = max_secondary_size
|
||||
|
||||
button.setFixedSize(QSize(button_width, button_height))
|
||||
|
||||
# Update icon size if it exists
|
||||
if has_icon and i < len(self._original_icon_paths) and self._original_icon_paths[i]:
|
||||
icon_path = self._original_icon_paths[i]
|
||||
# Icon size is 40% of the smaller dimension
|
||||
icon_size = int(min(button_width, button_height) * 0.4)
|
||||
is_selected = button.isChecked()
|
||||
color = self.selected_icon_color if is_selected else self.unselected_icon_color
|
||||
colored_icon = self.apply_color_to_svg_icon(icon_path, color)
|
||||
pixmap = colored_icon.pixmap(QSize(icon_size, icon_size))
|
||||
button.icon_label.setPixmap(pixmap)
|
||||
button.icon_label.setFixedSize(QSize(icon_size, icon_size))
|
||||
|
||||
def _update_all_button_sizes(self):
|
||||
"""Update all button sizes when container is resized"""
|
||||
if hasattr(self, '_square_buttons') and self._square_buttons:
|
||||
self._recalculate_all_buttons_size()
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Handle show event to set initial button sizes"""
|
||||
super().showEvent(event)
|
||||
# Update button sizes when widget is first shown
|
||||
self._update_all_button_sizes()
|
||||
|
||||
def _insert_button_with_alignment(self, button, position, after_button_index=None):
|
||||
"""Insert button at the specified position with proper visual alignment"""
|
||||
if position == ButtonPosition.START:
|
||||
# Insert at the beginning (before left spacer)
|
||||
self.button_layout.insertWidget(0, button)
|
||||
self.start_buttons.append(button)
|
||||
|
||||
elif position == ButtonPosition.CENTER:
|
||||
# Insert in center zone (after left spacer, before center spacer)
|
||||
center_start_index = self._get_spacer_index(self.left_spacer) + 1
|
||||
insert_index = center_start_index + len(self.center_buttons)
|
||||
self.button_layout.insertWidget(insert_index, button)
|
||||
self.center_buttons.append(button)
|
||||
|
||||
elif position == ButtonPosition.END:
|
||||
# Insert in end zone (after center spacer, at the end)
|
||||
end_start_index = self._get_spacer_index(self.center_spacer) + 1
|
||||
insert_index = end_start_index + len(self.end_buttons)
|
||||
self.button_layout.insertWidget(insert_index, button)
|
||||
self.end_buttons.append(button)
|
||||
|
||||
elif position == ButtonPosition.AFTER and after_button_index is not None:
|
||||
if 0 <= after_button_index < len(self.buttons) - 1:
|
||||
target_button = self.buttons[after_button_index]
|
||||
target_position = self.button_positions[after_button_index]
|
||||
|
||||
# Find the target button's layout index
|
||||
target_layout_index = -1
|
||||
for i in range(self.button_layout.count()):
|
||||
item = self.button_layout.itemAt(i)
|
||||
if item.widget() == target_button:
|
||||
target_layout_index = i
|
||||
break
|
||||
|
||||
if target_layout_index != -1:
|
||||
self.button_layout.insertWidget(target_layout_index + 1, button)
|
||||
|
||||
# Add to the same alignment zone as target
|
||||
if target_position == ButtonPosition.START:
|
||||
self.start_buttons.append(button)
|
||||
elif target_position == ButtonPosition.CENTER:
|
||||
self.center_buttons.append(button)
|
||||
elif target_position == ButtonPosition.END:
|
||||
self.end_buttons.append(button)
|
||||
else:
|
||||
# Fallback to END position
|
||||
self._insert_button_with_alignment(button, ButtonPosition.END)
|
||||
|
||||
def _get_spacer_index(self, spacer_item):
|
||||
"""Get the index of a spacer item in the layout"""
|
||||
for i in range(self.button_layout.count()):
|
||||
if self.button_layout.itemAt(i).spacerItem() == spacer_item:
|
||||
return i
|
||||
return -1
|
||||
|
||||
def set_button_size_ratio(self, ratio, button_index=None):
|
||||
"""Deprecated: Button sizes now adapt automatically to content"""
|
||||
pass
|
||||
|
||||
def switch_to_tab(self, index):
|
||||
"""Switch to the specified tab (only for widgets, not simple buttons)"""
|
||||
if 0 <= index < len(self.buttons) and self.widgets[index] is not None:
|
||||
old_index = self.stacked_widget.currentIndex()
|
||||
|
||||
# Update button states and apply theme colors
|
||||
for i, button in enumerate(self.buttons):
|
||||
# Only handle tab-like behavior for buttons with widgets
|
||||
if self.widgets[i] is not None:
|
||||
is_selected = (i == index)
|
||||
button.setChecked(is_selected)
|
||||
|
||||
# Update icon color based on selection
|
||||
if hasattr(button, 'icon_label') and i < len(self._original_icon_paths):
|
||||
icon_path = self._original_icon_paths[i]
|
||||
if icon_path:
|
||||
color = (self.selected_icon_color if is_selected
|
||||
else self.unselected_icon_color)
|
||||
colored_icon = self.apply_color_to_svg_icon(icon_path, color)
|
||||
|
||||
# Get current icon size
|
||||
current_size = button.icon_label.size()
|
||||
pixmap = colored_icon.pixmap(current_size)
|
||||
button.icon_label.setPixmap(pixmap)
|
||||
|
||||
# Update button property for styling
|
||||
button.setProperty("selected", is_selected)
|
||||
button.style().unpolish(button)
|
||||
button.style().polish(button)
|
||||
|
||||
# Switch stacked widget
|
||||
self.stacked_widget.setCurrentIndex(index)
|
||||
|
||||
# Call the callback if provided and index actually changed
|
||||
if self.onTabChange and old_index != index:
|
||||
try:
|
||||
self.onTabChange(index)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_theme(self):
|
||||
self.selected_icon_color = self.theme_manager.current_theme.get_color("icon_selected_color")
|
||||
self.unselected_icon_color = self.theme_manager.current_theme.get_color("icon_unselected_color")
|
||||
self.selected_border_icon_color = self.theme_manager.current_theme.get_color("icon_selected_border_color")
|
||||
self.hover_icon_color = self.theme_manager.current_theme.get_color("icon_hover_color")
|
||||
# Apply theme to all buttons
|
||||
for i, button in enumerate(self.buttons):
|
||||
# Check if button is currently selected
|
||||
is_selected = button.isChecked()
|
||||
|
||||
# Update button stylesheet with current theme colors
|
||||
border_style = self._get_border_style()
|
||||
button.setStyleSheet(border_style)
|
||||
|
||||
# Update icon color if icon_label exists
|
||||
if hasattr(button, 'icon_label') and i < len(self._original_icon_paths):
|
||||
icon_path = self._original_icon_paths[i]
|
||||
if icon_path:
|
||||
# Choose color based on selection state
|
||||
color = self.selected_icon_color if is_selected else self.unselected_icon_color
|
||||
|
||||
# Apply color to SVG and create new QIcon
|
||||
colored_icon = self.apply_color_to_svg_icon(icon_path, color)
|
||||
current_size = button.icon_label.size()
|
||||
pixmap = colored_icon.pixmap(current_size)
|
||||
button.icon_label.setPixmap(pixmap)
|
||||
|
||||
# Apply button styling based on selection state
|
||||
button.setProperty("selected", is_selected)
|
||||
|
||||
# Force style update
|
||||
button.style().unpolish(button)
|
||||
button.style().polish(button)
|
||||
|
||||
def update_button_text(self, index, new_text):
|
||||
"""Update the text of a button at the specified index"""
|
||||
if 0 <= index < len(self.buttons):
|
||||
button = self.buttons[index]
|
||||
if hasattr(button, 'text_label') and button.text_label is not None:
|
||||
button.text_label.setText(new_text)
|
||||
# Optionally, update button size after text change
|
||||
self._update_button_size(button)
|
||||
|
||||
def apply_color_to_svg_icon(self, icon_path, color) -> QIcon:
|
||||
"""
|
||||
Create or reuse a colored copy of the SVG in the user's data folder (temp_icons)
|
||||
and return a QIcon that points to it. Original SVG is preserved.
|
||||
|
||||
Caching: deterministic filename based on sha256(icon_path + color) so repeated calls reuse files.
|
||||
"""
|
||||
try:
|
||||
if not icon_path:
|
||||
return QIcon()
|
||||
|
||||
# deterministic filename based on hash to avoid duplicates
|
||||
hasher = hashlib.sha256()
|
||||
hasher.update(str(Path(icon_path).resolve()).encode('utf-8'))
|
||||
hasher.update(str(color).encode('utf-8'))
|
||||
digest = hasher.hexdigest()
|
||||
new_name = f"{Path(icon_path).stem}_{digest}.svg"
|
||||
|
||||
app_name = self.main_manager.get_settings_manager().get_config("app_name")
|
||||
temp_dir = Path(paths.get_user_temp_icons(app_name))
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tmp_path = temp_dir / new_name
|
||||
|
||||
# If exists in cache and file exists on disk, return immediately
|
||||
cache_key = (str(Path(icon_path).resolve()), color)
|
||||
if cache_key in self._icon_cache:
|
||||
cached_path = Path(self._icon_cache[cache_key])
|
||||
if cached_path.exists():
|
||||
return QIcon(str(cached_path))
|
||||
else:
|
||||
# stale cache entry -> remove
|
||||
del self._icon_cache[cache_key]
|
||||
|
||||
# If file already exists (from previous run), reuse it
|
||||
if tmp_path.exists():
|
||||
self._icon_cache[cache_key] = str(tmp_path)
|
||||
return QIcon(str(tmp_path))
|
||||
|
||||
# Parse original SVG
|
||||
tree = ET.parse(icon_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Apply fill/stroke to root and to all relevant elements.
|
||||
for el in root.iter():
|
||||
tag = el.tag
|
||||
if not isinstance(tag, str):
|
||||
continue
|
||||
lname = tag.split('}')[-1] # local name
|
||||
if lname in ("svg", "g", "path", "rect", "circle", "ellipse", "polygon", "line", "polyline"):
|
||||
if 'fill' in el.attrib:
|
||||
el.attrib['fill'] = color
|
||||
if 'stroke' in el.attrib:
|
||||
el.attrib['stroke'] = color
|
||||
|
||||
style = el.attrib.get('style')
|
||||
if style:
|
||||
parts = [p.strip() for p in style.split(';') if p.strip()]
|
||||
parts = [p for p in parts if not p.startswith('fill:') and not p.startswith('stroke:')]
|
||||
parts.append(f'fill:{color}')
|
||||
parts.append(f'stroke:{color}')
|
||||
el.attrib['style'] = ';'.join(parts)
|
||||
|
||||
# Write to deterministic file
|
||||
tree.write(tmp_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
# Update cache
|
||||
self._icon_cache[cache_key] = str(tmp_path)
|
||||
|
||||
return QIcon(str(tmp_path))
|
||||
except Exception:
|
||||
# On any error, fallback to original icon (do not crash)
|
||||
try:
|
||||
return QIcon(icon_path)
|
||||
except Exception:
|
||||
return QIcon()
|
||||
|
||||
def update_buttons_size(self, size):
|
||||
"""Update all buttons size"""
|
||||
self._update_all_button_sizes()
|
||||
241
app/ui/windows/activation_window.py
Normal file
241
app/ui/windows/activation_window.py
Normal file
@ -0,0 +1,241 @@
|
||||
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QPushButton, QFrame, QSizePolicy)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from app.core.main_manager import MainManager, NotificationType
|
||||
from app.ui.widgets.loading_bar import LoadingBar
|
||||
import webbrowser
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import app.utils.paths as paths
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv(paths.resource_path(".env"))
|
||||
|
||||
class ActivationThread(QThread):
|
||||
"""Thread pour l'activation afin de ne pas bloquer l'UI"""
|
||||
finished = pyqtSignal(dict)
|
||||
progress = pyqtSignal(int)
|
||||
|
||||
def __init__(self, license_manager, license_key):
|
||||
super().__init__()
|
||||
self.license_manager = license_manager
|
||||
self.license_key = license_key
|
||||
|
||||
def run(self):
|
||||
self.progress.emit(30)
|
||||
result = self.license_manager.activate_license(self.license_key)
|
||||
self.progress.emit(100)
|
||||
self.finished.emit(result)
|
||||
|
||||
class ActivationWindow(QWidget):
|
||||
"""Fenêtre d'activation de licence modernisée"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.main_manager = MainManager.get_instance()
|
||||
self.license_manager = self.main_manager.get_license_manager()
|
||||
self.language_manager = self.main_manager.get_language_manager()
|
||||
self.theme_manager = self.main_manager.get_theme_manager()
|
||||
self.alert_manager = self.main_manager.get_alert_manager()
|
||||
self.observer_manager = self.main_manager.get_observer_manager()
|
||||
self.settings_manager = self.main_manager.get_settings_manager()
|
||||
|
||||
self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language)
|
||||
self.observer_manager.subscribe(NotificationType.THEME, self.update_theme)
|
||||
|
||||
self.activation_thread = None
|
||||
|
||||
self.setup_ui()
|
||||
self.update_license_status()
|
||||
|
||||
def setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(20)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
# === Section titre ===
|
||||
self.title_label = QLabel(self.language_manager.get_text("activate_license"))
|
||||
self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
self.title_label.setWordWrap(True)
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
# === Spacer flexible ===
|
||||
layout.addStretch(1)
|
||||
|
||||
# === Section clé de licence ===
|
||||
key_layout = QHBoxLayout()
|
||||
|
||||
self.key_title = QLabel(self.language_manager.get_text("license_key_section"))
|
||||
self.key_title.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
key_layout.addWidget(self.key_title)
|
||||
|
||||
self.key_input = QLineEdit()
|
||||
self.key_input.setPlaceholderText("XXXX-XXXX-XXXX-XXXX-XXXX")
|
||||
self.key_input.textChanged.connect(self.format_license_key)
|
||||
self.key_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
key_layout.addWidget(self.key_input)
|
||||
|
||||
layout.addLayout(key_layout)
|
||||
# === Barre de progression ===
|
||||
self.progress_bar = LoadingBar(self.language_manager.get_text("loading"))
|
||||
self.progress_bar.setVisible(False)
|
||||
self.progress_bar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# === Spacer flexible ===
|
||||
layout.addStretch(1)
|
||||
|
||||
# === Section statut ===
|
||||
self.status_label = QLabel()
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# === Spacer flexible ===
|
||||
layout.addStretch(1)
|
||||
|
||||
# === Boutons d'action ===
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(10)
|
||||
|
||||
self.activate_btn = QPushButton(self.language_manager.get_text("activate"))
|
||||
self.activate_btn.clicked.connect(self.activate_license)
|
||||
self.activate_btn.setDefault(True)
|
||||
self.activate_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
button_layout.addWidget(self.activate_btn)
|
||||
|
||||
self.buy_btn = QPushButton(self.language_manager.get_text("buy_license"))
|
||||
self.buy_btn.clicked.connect(self.open_purchase_page)
|
||||
self.buy_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
button_layout.addWidget(self.buy_btn)
|
||||
|
||||
self.compare_btn = QPushButton(self.language_manager.get_text("compare_versions"))
|
||||
self.compare_btn.clicked.connect(self.show_features_comparison)
|
||||
self.compare_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
button_layout.addWidget(self.compare_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def show_features_comparison(self):
|
||||
"""Affiche la comparaison des versions dans une alerte"""
|
||||
comparison_text = f"{self.language_manager.get_text('comparaisons')}\n\n"
|
||||
|
||||
for version in self.settings_manager.get_config("features_by_license").keys():
|
||||
comparison_text += f"{self.language_manager.get_text(version+'_version')}:\n"
|
||||
features = self.settings_manager.get_config("features_by_license").get(version, [])
|
||||
for feature in features:
|
||||
feature_desc = self.language_manager.get_text(feature)
|
||||
comparison_text += f" • {feature_desc}\n"
|
||||
comparison_text += "\n"
|
||||
|
||||
|
||||
self.alert_manager.show_info(comparison_text, parent=self)
|
||||
|
||||
def update_license_status(self):
|
||||
"""Met à jour l'affichage du statut de la licence"""
|
||||
if self.license_manager.is_activated():
|
||||
license_info = self.license_manager.get_license_info()
|
||||
license_type = license_info.get("type", "free").upper()
|
||||
|
||||
status_text = f"✓ {self.language_manager.get_text('license_active')}\n"
|
||||
status_text += f"{self.language_manager.get_text('license_type')}: {license_type}\n"
|
||||
if license_info.get("email"):
|
||||
status_text += f"{self.language_manager.get_text('license_email')}: {license_info['email']}\n"
|
||||
if license_info.get("expires_at"):
|
||||
status_text += f"{self.language_manager.get_text('license_expires')}: {license_info['expires_at']}"
|
||||
|
||||
self.status_label.setText(status_text)
|
||||
|
||||
self.key_input.setEnabled(False)
|
||||
self.activate_btn.setEnabled(False)
|
||||
else:
|
||||
status_text = f"{self.language_manager.get_text('no_license')}"
|
||||
self.status_label.setText(status_text)
|
||||
|
||||
def format_license_key(self, text):
|
||||
"""Formate automatiquement la clé de licence (XXXX-XXXX-...)"""
|
||||
text = text.replace("-", "").upper()
|
||||
formatted = "-".join([text[i:i+4] for i in range(0, len(text), 4)])
|
||||
if len(formatted) > 24:
|
||||
formatted = formatted[:24]
|
||||
self.key_input.blockSignals(True)
|
||||
self.key_input.setText(formatted)
|
||||
self.key_input.blockSignals(False)
|
||||
self.key_input.setCursorPosition(len(formatted))
|
||||
|
||||
def activate_license(self):
|
||||
"""Lance l'activation de la licence"""
|
||||
license_key = self.key_input.text().replace("-", "")
|
||||
|
||||
if len(license_key) < 16:
|
||||
self.alert_manager.show_error("invalid_license_key")
|
||||
return
|
||||
|
||||
# Disable inputs during activation
|
||||
self.activate_btn.setEnabled(False)
|
||||
self.key_input.setEnabled(False)
|
||||
self.buy_btn.setEnabled(False)
|
||||
|
||||
# Show progress bar with initial message
|
||||
self.progress_bar.set_label(self.language_manager.get_text("loading"))
|
||||
self.progress_bar.set_progress(0)
|
||||
self.progress_bar.setVisible(True)
|
||||
|
||||
# Start activation thread
|
||||
self.activation_thread = ActivationThread(self.license_manager, license_key)
|
||||
self.activation_thread.finished.connect(self.on_activation_finished)
|
||||
self.activation_thread.progress.connect(self.on_activation_progress)
|
||||
self.activation_thread.start()
|
||||
|
||||
def on_activation_progress(self, value):
|
||||
"""Update progress bar during activation"""
|
||||
self.progress_bar.set_progress(value)
|
||||
|
||||
def on_activation_finished(self, result):
|
||||
"""Callback quand l'activation est terminée"""
|
||||
# Hide progress bar
|
||||
self.progress_bar.setVisible(False)
|
||||
|
||||
# Re-enable inputs
|
||||
self.activate_btn.setEnabled(True)
|
||||
self.key_input.setEnabled(True)
|
||||
self.buy_btn.setEnabled(True)
|
||||
|
||||
if result["success"]:
|
||||
# Show success message using AlertManager
|
||||
success_msg = result['message']
|
||||
if result.get("data"):
|
||||
success_msg += f"\n\n{self.language_manager.get_text('license_type')}: {result['data'].get('type', 'N/A')}"
|
||||
success_msg += f"\n{self.language_manager.get_text('license_email')}: {result['data'].get('email', 'N/A')}"
|
||||
|
||||
self.alert_manager.show_info(success_msg, parent=self)
|
||||
self.update_license_status()
|
||||
|
||||
# Clear the input field
|
||||
self.key_input.clear()
|
||||
else:
|
||||
# Show error message using AlertManager
|
||||
self.alert_manager.show_info(f"✗ {result['message']}", parent=self)
|
||||
|
||||
def open_purchase_page(self):
|
||||
"""Ouvre la page d'achat dans le navigateur"""
|
||||
purchase_url = os.getenv("PURCHASE_URL")
|
||||
if purchase_url:
|
||||
webbrowser.open(purchase_url)
|
||||
else:
|
||||
self.alert_manager.show_error("PURCHASE_URL non définie dans .env", parent=self)
|
||||
|
||||
def update_language(self):
|
||||
"""Met à jour tous les textes selon la nouvelle langue"""
|
||||
self.title_label.setText(self.language_manager.get_text("activate_license"))
|
||||
self.activate_btn.setText(self.language_manager.get_text("activate"))
|
||||
self.buy_btn.setText(self.language_manager.get_text("buy_license"))
|
||||
self.compare_btn.setText(self.language_manager.get_text("compare_versions"))
|
||||
self.progress_bar.set_label(self.language_manager.get_text("loading"))
|
||||
self.key_title.setText(self.language_manager.get_text("license_key_section"))
|
||||
self.update_license_status()
|
||||
|
||||
def update_theme(self):
|
||||
"""Met à jour le style selon le nouveau thème"""
|
||||
self.update_license_status()
|
||||
105
app/ui/windows/settings_window.py
Normal file
105
app/ui/windows/settings_window.py
Normal file
@ -0,0 +1,105 @@
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QLabel, QHBoxLayout, QSizePolicy
|
||||
from PyQt6.QtCore import Qt
|
||||
from app.core.main_manager import MainManager, NotificationType
|
||||
from typing import Optional
|
||||
|
||||
class SettingsWindow(QWidget):
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.main_manager: MainManager = MainManager.get_instance()
|
||||
self.language_manager = self.main_manager.get_language_manager()
|
||||
self.settings_manager = self.main_manager.get_settings_manager()
|
||||
self.theme_manager = self.main_manager.get_theme_manager()
|
||||
|
||||
self.observer_manager = self.main_manager.get_observer_manager()
|
||||
self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language)
|
||||
|
||||
# Type hints for UI elements
|
||||
self.language_layout: QHBoxLayout
|
||||
self.languageLabel: QLabel
|
||||
self.languageCombo: QComboBox
|
||||
self.theme_layout: QHBoxLayout
|
||||
self.themeLabel: QLabel
|
||||
self.themeCombo: QComboBox
|
||||
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self) -> None:
|
||||
layout: QVBoxLayout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setSpacing(20)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
self.language_layout = QHBoxLayout()
|
||||
# Paramètres de langue
|
||||
self.languageLabel = QLabel(self.language_manager.get_text("language"),self)
|
||||
self.languageLabel.setMinimumWidth(100)
|
||||
self.languageLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
self.language_layout.addWidget(self.languageLabel)
|
||||
|
||||
self.languageCombo = self.createLanguageSelector()
|
||||
self.languageCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.language_layout.addWidget(self.languageCombo)
|
||||
|
||||
layout.addLayout(self.language_layout)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
# Paramètres de thème
|
||||
self.theme_layout = QHBoxLayout()
|
||||
|
||||
self.themeLabel = QLabel(self.language_manager.get_text("theme"), self)
|
||||
self.themeLabel.setMinimumWidth(100)
|
||||
self.themeLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
self.theme_layout.addWidget(self.themeLabel)
|
||||
|
||||
self.themeCombo = self.createThemeSelector()
|
||||
self.themeCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.theme_layout.addWidget(self.themeCombo)
|
||||
|
||||
layout.addLayout(self.theme_layout)
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
def createLanguageSelector(self) -> QComboBox:
|
||||
combo: QComboBox = QComboBox()
|
||||
# Ajouter toutes les langues disponibles
|
||||
for langCode, langData in self.language_manager.translations.items():
|
||||
combo.addItem(langData["lang_name"], langCode)
|
||||
|
||||
# Sélectionner la langue actuelle
|
||||
currentIndex = combo.findData(self.settings_manager.get_language())
|
||||
combo.setCurrentIndex(currentIndex)
|
||||
combo.currentIndexChanged.connect(self.change_language)
|
||||
|
||||
return combo
|
||||
|
||||
def createThemeSelector(self) -> QComboBox:
|
||||
combo: QComboBox = QComboBox()
|
||||
# Ajouter toutes les options de thème disponibles
|
||||
for theme in self.theme_manager.get_themes():
|
||||
combo.addItem(self.language_manager.get_text(theme.name+"_theme"), theme.name)
|
||||
|
||||
# Sélectionner le thème actuel
|
||||
currentIndex = combo.findData(self.settings_manager.get_theme())
|
||||
combo.setCurrentIndex(currentIndex)
|
||||
combo.currentIndexChanged.connect(self.change_theme)
|
||||
|
||||
return combo
|
||||
|
||||
def change_language(self, index: int) -> None:
|
||||
self.settings_manager.set_language(self.languageCombo.itemData(index))
|
||||
|
||||
def change_theme(self, index: int) -> None:
|
||||
theme: str = self.themeCombo.itemData(index)
|
||||
self.settings_manager.set_theme(theme)
|
||||
|
||||
def update_language(self) -> None:
|
||||
self.languageLabel.setText(self.language_manager.get_text("language"))
|
||||
self.themeLabel.setText(self.language_manager.get_text("theme"))
|
||||
|
||||
# Mettre à jour les textes dans la combo de thème
|
||||
for i in range(self.themeCombo.count()):
|
||||
self.themeCombo.setItemText(i, self.language_manager.get_text(self.themeCombo.itemData(i)+ "_theme"))
|
||||
159
app/ui/windows/splash_screen.py
Normal file
159
app/ui/windows/splash_screen.py
Normal file
@ -0,0 +1,159 @@
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
||||
from PyQt6.QtGui import QPixmap
|
||||
from app.core.main_manager import MainManager
|
||||
from app.ui.widgets.loading_spinner import LoadingSpinner
|
||||
import app.utils.paths as paths
|
||||
|
||||
class SplashScreen(QWidget):
|
||||
finished = pyqtSignal(bool) # True si succès, False si échec/interruption
|
||||
|
||||
def __init__(self, parent=None, preload_function=None):
|
||||
super().__init__(parent)
|
||||
self.preload_function = preload_function
|
||||
self.preload_result = True
|
||||
|
||||
self.main_manager = MainManager.get_instance()
|
||||
self.theme_manager = self.main_manager.get_theme_manager()
|
||||
self.settings_manager = self.main_manager.get_settings_manager()
|
||||
self.language_manager = self.main_manager.get_language_manager()
|
||||
|
||||
self.setup_ui()
|
||||
if self.preload_function:
|
||||
self.start_preloading()
|
||||
else:
|
||||
# Pas de préchargement, fermer immédiatement
|
||||
QTimer.singleShot(100, lambda: self.finished.emit(True))
|
||||
|
||||
def setup_ui(self):
|
||||
# Configuration de la fenêtre
|
||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setFixedSize(2000, 2000)
|
||||
|
||||
# Layout principal
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setSpacing(60)
|
||||
layout.setContentsMargins(80, 80, 80, 80)
|
||||
|
||||
# Image splash
|
||||
self.image_label = QLabel()
|
||||
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.load_splash_image()
|
||||
layout.addWidget(self.image_label)
|
||||
|
||||
# Texte de progression
|
||||
self.progress_label = QLabel("Chargement...")
|
||||
self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.progress_label.setStyleSheet("font-size: 14px; color: #666;")
|
||||
layout.addWidget(self.progress_label)
|
||||
|
||||
# Spinner de chargement
|
||||
self.spinner = LoadingSpinner(50, self)
|
||||
spinner_layout = QVBoxLayout()
|
||||
spinner_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
spinner_layout.addWidget(self.spinner)
|
||||
layout.addLayout(spinner_layout)
|
||||
|
||||
# Appliquer le thème
|
||||
self.apply_theme()
|
||||
|
||||
# Centrer la fenêtre
|
||||
self.center_on_screen()
|
||||
|
||||
def start_preloading(self):
|
||||
"""Démarre le préchargement avec un délai pour permettre l'affichage du splash"""
|
||||
# Laisser le temps au splash de s'afficher
|
||||
QTimer.singleShot(200, self.do_preloading)
|
||||
|
||||
def do_preloading(self):
|
||||
"""Effectue le préchargement dans le thread principal"""
|
||||
try:
|
||||
# Fonction callback pour mettre à jour le texte
|
||||
def progress_callback(text):
|
||||
self.progress_label.setText(text)
|
||||
# Traiter les événements pour que l'UI se mette à jour
|
||||
QApplication.processEvents()
|
||||
|
||||
# Appeler la fonction de préchargement
|
||||
success = self.preload_function(progress_callback)
|
||||
self.preload_result = success
|
||||
|
||||
except Exception:
|
||||
self.preload_result = False
|
||||
|
||||
# Attendre un peu puis fermer
|
||||
QTimer.singleShot(300, self.close_splash)
|
||||
|
||||
def close_splash(self):
|
||||
"""Ferme le splash screen et émet le signal"""
|
||||
if hasattr(self, 'spinner'):
|
||||
self.spinner.stop()
|
||||
self.finished.emit(self.preload_result)
|
||||
self.close()
|
||||
|
||||
def load_splash_image(self):
|
||||
"""Charge l'image splash depuis la config"""
|
||||
try:
|
||||
splash_image_path = paths.get_asset_path(self.settings_manager.get_config("splash_image"))
|
||||
if splash_image_path:
|
||||
# Essayer le chemin depuis la config
|
||||
if not splash_image_path.startswith('/') and not splash_image_path.startswith('\\') and ':' not in splash_image_path:
|
||||
# Chemin relatif, le résoudre depuis le dossier assets
|
||||
splash_image_path = paths.get_asset_path(splash_image_path)
|
||||
|
||||
pixmap = QPixmap(splash_image_path)
|
||||
if not pixmap.isNull():
|
||||
# Redimensionner l'image
|
||||
scaled_pixmap = pixmap.scaled(400, 300, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||||
self.image_label.setPixmap(scaled_pixmap)
|
||||
return
|
||||
|
||||
# Fallback : essayer l'icône par défaut
|
||||
fallback_path = paths.get_asset_path("icon.png")
|
||||
pixmap = QPixmap(fallback_path)
|
||||
if not pixmap.isNull():
|
||||
scaled_pixmap = pixmap.scaled(240, 240, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||||
self.image_label.setPixmap(scaled_pixmap)
|
||||
else:
|
||||
# Dernier fallback : texte
|
||||
self.image_label.setText("🚀")
|
||||
self.image_label.setStyleSheet("font-size: 48px;")
|
||||
except Exception:
|
||||
# En cas d'erreur, afficher un emoji
|
||||
self.image_label.setText("🚀")
|
||||
self.image_label.setStyleSheet("font-size: 48px;")
|
||||
|
||||
def apply_theme(self):
|
||||
"""Applique le thème actuel"""
|
||||
theme = self.theme_manager.get_theme()
|
||||
|
||||
style = f"""
|
||||
QWidget {{
|
||||
background-color: {theme.get_color("background_color")};
|
||||
border-radius: 15px;
|
||||
border: 2px solid {theme.get_color("primary_color")};
|
||||
}}
|
||||
QLabel {{
|
||||
color: {theme.get_color("text_color")};
|
||||
background: transparent;
|
||||
border: none;
|
||||
}}
|
||||
"""
|
||||
self.setStyleSheet(style)
|
||||
|
||||
def center_on_screen(self):
|
||||
"""Centre la fenêtre sur l'écran"""
|
||||
screen = QApplication.primaryScreen()
|
||||
screen_geometry = screen.geometry()
|
||||
|
||||
x = (screen_geometry.width() - self.width()) // 2
|
||||
y = (screen_geometry.height() - self.height()) // 2
|
||||
self.move(x, y)
|
||||
|
||||
def show_splash(self):
|
||||
"""Affiche le splash screen"""
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
143
app/ui/windows/suggestion_window.py
Normal file
143
app/ui/windows/suggestion_window.py
Normal file
@ -0,0 +1,143 @@
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QPushButton, QLabel, QHBoxLayout, QSizePolicy
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
import smtplib, os
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from dotenv import load_dotenv
|
||||
from app.core.main_manager import MainManager, NotificationType
|
||||
from typing import Optional
|
||||
import app.utils.paths as path
|
||||
# Load environment variables from .env file
|
||||
load_dotenv(path.resource_path(".env"))
|
||||
|
||||
class EmailSender(QThread):
|
||||
success = pyqtSignal()
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, subject: str, message: str) -> None:
|
||||
super().__init__()
|
||||
self.subject: str = subject
|
||||
self.message: str = message
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
# Get email configuration from environment variables
|
||||
email: Optional[str] = os.getenv('EMAIL_ADDRESS')
|
||||
password: Optional[str] = os.getenv('EMAIL_PASSWORD')
|
||||
smtp_server: str = os.getenv('EMAIL_SMTP_SERVER', 'smtp.gmail.com')
|
||||
smtp_port: int = int(os.getenv('EMAIL_SMTP_PORT', '587'))
|
||||
|
||||
if not email or not password:
|
||||
self.error.emit("password")
|
||||
return
|
||||
|
||||
# Create message
|
||||
msg: MIMEMultipart = MIMEMultipart()
|
||||
msg['From'] = email
|
||||
msg['To'] = email
|
||||
msg['Subject'] = self.subject
|
||||
|
||||
# Add body to email
|
||||
msg.attach(MIMEText(self.message, 'plain'))
|
||||
|
||||
# Create SMTP session
|
||||
server: smtplib.SMTP = smtplib.SMTP(smtp_server, smtp_port)
|
||||
server.starttls() # Enable TLS encryption
|
||||
# Login with app password
|
||||
server.login(email, password)
|
||||
# Send email
|
||||
text: str = msg.as_string()
|
||||
server.sendmail(email, email, text)
|
||||
server.quit()
|
||||
self.success.emit()
|
||||
except smtplib.SMTPAuthenticationError:
|
||||
self.error.emit("email_credentials_error")
|
||||
except Exception:
|
||||
self.error.emit("suggestion_send_error")
|
||||
|
||||
class SuggestionWindow(QWidget):
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.main_manager: MainManager = MainManager.get_instance()
|
||||
self.language_manager = self.main_manager.get_language_manager()
|
||||
self.settings_manager = self.main_manager.get_settings_manager()
|
||||
self.alert_manager = self.main_manager.get_alert_manager()
|
||||
self.license_manager = self.main_manager.get_license_manager()
|
||||
|
||||
self.observer_manager = self.main_manager.get_observer_manager()
|
||||
self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language)
|
||||
|
||||
self.email_sender: Optional[EmailSender] = None
|
||||
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self) -> None:
|
||||
layout: QVBoxLayout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
layout.setSpacing(20)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
||||
# Title
|
||||
self.title_label: QLabel = QLabel(self.language_manager.get_text("suggestion_text"), self)
|
||||
self.title_label.setWordWrap(True)
|
||||
self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
# Text area for suggestion
|
||||
self.text_edit: QTextEdit = QTextEdit(self)
|
||||
self.text_edit.setPlaceholderText(self.language_manager.get_text("suggestion_placeholder"))
|
||||
self.text_edit.setMinimumHeight(150)
|
||||
self.text_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout.addWidget(self.text_edit, 1)
|
||||
|
||||
# Button layout
|
||||
button_layout: QHBoxLayout = QHBoxLayout()
|
||||
button_layout.addStretch()
|
||||
|
||||
# Send button
|
||||
self.send_button: QPushButton = QPushButton(self.language_manager.get_text("send_suggestion"), self)
|
||||
self.send_button.clicked.connect(self.send_suggestion)
|
||||
button_layout.addWidget(self.send_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def send_suggestion(self) -> None:
|
||||
message: str = self.text_edit.toPlainText().strip()
|
||||
|
||||
if len(message)<15:
|
||||
self.alert_manager.show_error("suggestion_too_short")
|
||||
return
|
||||
|
||||
# Disable send button during sending
|
||||
self.send_button.setEnabled(False)
|
||||
self.send_button.setText(self.language_manager.get_text("sending"))
|
||||
|
||||
content = self.settings_manager.get_config('app_name').replace(' ', '_')
|
||||
|
||||
# Ajouter le préfixe "PRIORITAIRE" uniquement si le système de licence est activé ET que l'utilisateur a le support prioritaire
|
||||
if self.settings_manager.get_config("enable_licensing") and self.license_manager.is_feature_available("priority_support"):
|
||||
subject = "PRIORITAIRE - "+content
|
||||
else:
|
||||
subject = "Suggestion pour "+content
|
||||
|
||||
# Create and start email sender thread
|
||||
self.email_sender = EmailSender(subject, message)
|
||||
self.email_sender.success.connect(self.on_email_sent)
|
||||
self.email_sender.error.connect(self.on_email_error)
|
||||
self.email_sender.start()
|
||||
|
||||
def on_email_sent(self) -> None:
|
||||
self.send_button.setEnabled(True)
|
||||
self.send_button.setText(self.language_manager.get_text("send_suggestion"))
|
||||
self.alert_manager.show_success("suggestion_sent_success")
|
||||
self.text_edit.clear()
|
||||
|
||||
def on_email_error(self, error: str) -> None:
|
||||
self.send_button.setEnabled(True)
|
||||
self.send_button.setText(self.language_manager.get_text("send_suggestion"))
|
||||
self.alert_manager.show_error(error)
|
||||
|
||||
def update_language(self) -> None:
|
||||
self.title_label.setText(self.language_manager.get_text("suggestion_text"))
|
||||
self.text_edit.setPlaceholderText(self.language_manager.get_text("suggestion_placeholder"))
|
||||
self.send_button.setText(self.language_manager.get_text("send_suggestion"))
|
||||
66
app/utils/licence.py
Normal file
66
app/utils/licence.py
Normal file
@ -0,0 +1,66 @@
|
||||
from functools import wraps
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
from app.core.main_manager import MainManager
|
||||
|
||||
def require_license(feature_id: str, show_upgrade_dialog: bool = True):
|
||||
"""
|
||||
Décorateur pour protéger une fonctionnalité premium
|
||||
|
||||
Usage:
|
||||
@require_license("advanced_export")
|
||||
def export_to_excel(self):
|
||||
# Code de la fonctionnalité premium
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
main_manager = MainManager.get_instance()
|
||||
license_manager = main_manager.get_license_manager()
|
||||
settings_manager = main_manager.get_settings_manager()
|
||||
|
||||
# Si le système de licence est désactivé, autoriser toutes les fonctionnalités
|
||||
if not settings_manager.get_config("enable_licensing"):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
if not license_manager.is_feature_available(feature_id):
|
||||
if show_upgrade_dialog:
|
||||
show_upgrade_message(feature_id)
|
||||
return None
|
||||
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
def show_upgrade_message(feature_id: str):
|
||||
"""Affiche un message invitant à acheter la version premium"""
|
||||
main_manager = MainManager.get_instance()
|
||||
language_manager = main_manager.get_language_manager()
|
||||
settings_manager = main_manager.get_settings_manager()
|
||||
|
||||
# Si le système de licence est désactivé, ne rien afficher
|
||||
if not settings_manager.get_config("enable_licensing"):
|
||||
return
|
||||
|
||||
feature_name = settings_manager.get_config("feature_descriptions", {}).get(
|
||||
feature_id, feature_id
|
||||
)
|
||||
|
||||
msg = QMessageBox()
|
||||
msg.setIcon(QMessageBox.Icon.Information)
|
||||
msg.setWindowTitle(language_manager.get_text("premium_feature"))
|
||||
msg.setText(language_manager.get_text("premium_feature_message").format(feature=feature_name))
|
||||
msg.setInformativeText(language_manager.get_text("upgrade_prompt"))
|
||||
|
||||
buy_btn = msg.addButton(language_manager.get_text("buy_now"), QMessageBox.ButtonRole.AcceptRole)
|
||||
activate_btn = msg.addButton(language_manager.get_text("activate_license"), QMessageBox.ButtonRole.ActionRole)
|
||||
msg.addButton(language_manager.get_text("cancel"), QMessageBox.ButtonRole.RejectRole)
|
||||
|
||||
msg.exec()
|
||||
|
||||
if msg.clickedButton() == buy_btn:
|
||||
import webbrowser
|
||||
webbrowser.open(settings_manager.get_config("purchase_url"))
|
||||
elif msg.clickedButton() == activate_btn:
|
||||
from app.ui.windows.activation_window import ActivationWindow
|
||||
activation_window = ActivationWindow()
|
||||
activation_window.exec()
|
||||
57
app/utils/paths.py
Normal file
57
app/utils/paths.py
Normal file
@ -0,0 +1,57 @@
|
||||
from os import path, getenv, mkdir
|
||||
import sys
|
||||
from platform import system
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
def resource_path(relative_path: str) -> str:
|
||||
"""
|
||||
Get absolute path to resource, works for dev and for PyInstaller bundle.
|
||||
|
||||
PyInstaller stores bundled files in _MEIPASS folder.
|
||||
"""
|
||||
try:
|
||||
base_path: Path = Path(sys._MEIPASS) # PyInstaller temp folder
|
||||
except AttributeError:
|
||||
base_path: Path = Path(__file__).parent.parent.parent # Dev environment: source/ folder
|
||||
|
||||
return path.join(base_path, relative_path)
|
||||
|
||||
def get_data_dir() -> str:
|
||||
return resource_path("data")
|
||||
|
||||
def get_lang_path() -> str:
|
||||
return path.join(get_data_dir(), "lang")
|
||||
|
||||
def get_asset_path(asset: str) -> str:
|
||||
return path.join(get_data_dir(), "assets", f"{asset}.png")
|
||||
|
||||
def get_asset_svg_path(asset: str) -> str:
|
||||
return path.join(get_data_dir(), "assets", f"{asset}.svg")
|
||||
|
||||
def get_user_data_dir(app_name: str) -> str:
|
||||
home: Path = Path.home()
|
||||
os: str = system()
|
||||
|
||||
if os == "Windows":
|
||||
appdata: Optional[str] = getenv('APPDATA')
|
||||
if appdata:
|
||||
user_data_path: str = path.join(Path(appdata), app_name)
|
||||
else:
|
||||
user_data_path: str = path.join(home, "AppData", "Roaming", app_name)
|
||||
elif os == "Darwin":
|
||||
user_data_path: str = path.join(home, "Library", "Application Support", app_name)
|
||||
else:
|
||||
user_data_path: str = path.join(home, ".local", "share", app_name)
|
||||
|
||||
if not path.exists(user_data_path):
|
||||
mkdir(user_data_path)
|
||||
return user_data_path
|
||||
|
||||
def get_user_temp_icons(app_name: str) -> str:
|
||||
user_data_path = get_user_data_dir(app_name)
|
||||
return path.join(user_data_path, "temp", "icons")
|
||||
|
||||
def get_user_temp(app_name: str) -> str:
|
||||
user_data_path = get_user_data_dir(app_name)
|
||||
return path.join(user_data_path, "temp")
|
||||
23
config.json
Normal file
23
config.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"app_name": "Application",
|
||||
"app_os": "Windows",
|
||||
"app_version": "1.0.0",
|
||||
"architecture": "x64",
|
||||
"icon_path": "data/assets/icon.ico",
|
||||
"splash_image": "splash",
|
||||
"main_script": "main.py",
|
||||
"git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate",
|
||||
"enable_licensing": true,
|
||||
"features_by_license": {
|
||||
"basic": [
|
||||
"support"
|
||||
],
|
||||
"premium": [
|
||||
"priority_support"
|
||||
],
|
||||
"enterprise": [
|
||||
"priority_support",
|
||||
"unlimited_key_uses"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
data/assets/icon.icns
Normal file
BIN
data/assets/icon.icns
Normal file
Binary file not shown.
BIN
data/assets/icon.ico
Normal file
BIN
data/assets/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
data/assets/icon.png
Normal file
BIN
data/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
4
data/assets/license.svg
Normal file
4
data/assets/license.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<ns0:svg width="800px" height="800px" viewBox="0 0 24 24" xmlns:ns0="http://www.w3.org/2000/svg">
|
||||
<ns0:path fill-rule="evenodd" clip-rule="evenodd" d="M15.6807 14.5869C19.1708 14.5869 22 11.7692 22 8.29344C22 4.81767 19.1708 2 15.6807 2C12.1907 2 9.3615 4.81767 9.3615 8.29344C9.3615 9.90338 10.0963 11.0743 10.0963 11.0743L2.45441 18.6849C2.1115 19.0264 1.63143 19.9143 2.45441 20.7339L3.33616 21.6121C3.67905 21.9048 4.54119 22.3146 5.2466 21.6121L6.27531 20.5876C7.30403 21.6121 8.4797 21.0267 8.92058 20.4412C9.65538 19.4167 8.77362 18.3922 8.77362 18.3922L9.06754 18.0995C10.4783 19.5045 11.7128 18.6849 12.1537 18.0995C12.8885 17.075 12.1537 16.0505 12.1537 16.0505C11.8598 15.465 11.272 15.465 12.0067 14.7333L12.8885 13.8551C13.5939 14.4405 15.0439 14.5869 15.6807 14.5869Z M17.8853 8.29353C17.8853 9.50601 16.8984 10.4889 15.681 10.4889C14.4635 10.4889 13.4766 9.50601 13.4766 8.29353C13.4766 7.08105 14.4635 6.09814 15.681 6.09814C16.8984 6.09814 17.8853 7.08105 17.8853 8.29353Z" fill="#D1D1D6"/>
|
||||
|
||||
</ns0:svg>
|
||||
3
data/assets/settings.svg
Normal file
3
data/assets/settings.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="100px" height="100px">
|
||||
<ns0:path d="M 22.205078 2 A 1.0001 1.0001 0 0 0 21.21875 2.8378906 L 20.246094 8.7929688 C 19.076509 9.1331971 17.961243 9.5922728 16.910156 10.164062 L 11.996094 6.6542969 A 1.0001 1.0001 0 0 0 10.708984 6.7597656 L 6.8183594 10.646484 A 1.0001 1.0001 0 0 0 6.7070312 11.927734 L 10.164062 16.873047 C 9.583454 17.930271 9.1142098 19.051824 8.765625 20.232422 L 2.8359375 21.21875 A 1.0001 1.0001 0 0 0 2.0019531 22.205078 L 2.0019531 27.705078 A 1.0001 1.0001 0 0 0 2.8261719 28.691406 L 8.7597656 29.742188 C 9.1064607 30.920739 9.5727226 32.043065 10.154297 33.101562 L 6.6542969 37.998047 A 1.0001 1.0001 0 0 0 6.7597656 39.285156 L 10.648438 43.175781 A 1.0001 1.0001 0 0 0 11.927734 43.289062 L 16.882812 39.820312 C 17.936999 40.39548 19.054994 40.857928 20.228516 41.201172 L 21.21875 47.164062 A 1.0001 1.0001 0 0 0 22.205078 48 L 27.705078 48 A 1.0001 1.0001 0 0 0 28.691406 47.173828 L 29.751953 41.1875 C 30.920633 40.838997 32.033372 40.369697 33.082031 39.791016 L 38.070312 43.291016 A 1.0001 1.0001 0 0 0 39.351562 43.179688 L 43.240234 39.287109 A 1.0001 1.0001 0 0 0 43.34375 37.996094 L 39.787109 33.058594 C 40.355783 32.014958 40.813915 30.908875 41.154297 29.748047 L 47.171875 28.693359 A 1.0001 1.0001 0 0 0 47.998047 27.707031 L 47.998047 22.207031 A 1.0001 1.0001 0 0 0 47.160156 21.220703 L 41.152344 20.238281 C 40.80968 19.078827 40.350281 17.974723 39.78125 16.931641 L 43.289062 11.933594 A 1.0001 1.0001 0 0 0 43.177734 10.652344 L 39.287109 6.7636719 A 1.0001 1.0001 0 0 0 37.996094 6.6601562 L 33.072266 10.201172 C 32.023186 9.6248101 30.909713 9.1579916 29.738281 8.8125 L 28.691406 2.828125 A 1.0001 1.0001 0 0 0 27.705078 2 L 22.205078 2 z M 23.056641 4 L 26.865234 4 L 27.861328 9.6855469 A 1.0001 1.0001 0 0 0 28.603516 10.484375 C 30.066026 10.848832 31.439607 11.426549 32.693359 12.185547 A 1.0001 1.0001 0 0 0 33.794922 12.142578 L 38.474609 8.7792969 L 41.167969 11.472656 L 37.835938 16.220703 A 1.0001 1.0001 0 0 0 37.796875 17.310547 C 38.548366 18.561471 39.118333 19.926379 39.482422 21.380859 A 1.0001 1.0001 0 0 0 40.291016 22.125 L 45.998047 23.058594 L 45.998047 26.867188 L 40.279297 27.871094 A 1.0001 1.0001 0 0 0 39.482422 28.617188 C 39.122545 30.069817 38.552234 31.434687 37.800781 32.685547 A 1.0001 1.0001 0 0 0 37.845703 33.785156 L 41.224609 38.474609 L 38.53125 41.169922 L 33.791016 37.84375 A 1.0001 1.0001 0 0 0 32.697266 37.808594 C 31.44975 38.567585 30.074755 39.148028 28.617188 39.517578 A 1.0001 1.0001 0 0 0 27.876953 40.3125 L 26.867188 46 L 23.052734 46 L 22.111328 40.337891 A 1.0001 1.0001 0 0 0 21.365234 39.53125 C 19.90185 39.170557 18.522094 38.59371 17.259766 37.835938 A 1.0001 1.0001 0 0 0 16.171875 37.875 L 11.46875 41.169922 L 8.7734375 38.470703 L 12.097656 33.824219 A 1.0001 1.0001 0 0 0 12.138672 32.724609 C 11.372652 31.458855 10.793319 30.079213 10.427734 28.609375 A 1.0001 1.0001 0 0 0 9.6328125 27.867188 L 4.0019531 26.867188 L 4.0019531 23.052734 L 9.6289062 22.117188 A 1.0001 1.0001 0 0 0 10.435547 21.373047 C 10.804273 19.898143 11.383325 18.518729 12.146484 17.255859 A 1.0001 1.0001 0 0 0 12.111328 16.164062 L 8.8261719 11.46875 L 11.523438 8.7734375 L 16.185547 12.105469 A 1.0001 1.0001 0 0 0 17.28125 12.148438 C 18.536908 11.394293 19.919867 10.822081 21.384766 10.462891 A 1.0001 1.0001 0 0 0 22.132812 9.6523438 L 23.056641 4 z M 25 17 C 20.593567 17 17 20.593567 17 25 C 17 29.406433 20.593567 33 25 33 C 29.406433 33 33 29.406433 33 25 C 33 20.593567 29.406433 17 25 17 z M 25 19 C 28.325553 19 31 21.674447 31 25 C 31 28.325553 28.325553 31 25 31 C 21.674447 31 19 28.325553 19 25 C 19 21.674447 21.674447 19 25 19 z" fill="#D1D1D6"/>
|
||||
</ns0:svg>
|
||||
BIN
data/assets/splash.png
Normal file
BIN
data/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
9
data/assets/suggestion.svg
Normal file
9
data/assets/suggestion.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="0 0 64 64" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve">
|
||||
|
||||
<ns0:g id="ICON">
|
||||
|
||||
<ns0:path d="M59,43l-54,0l-0,-9c-0,-0.552 -0.448,-1 -1,-1c-0.552,-0 -1,0.448 -1,1c-0,5.651 -0,11.154 -0,14c-0,0.796 0.316,1.559 0.879,2.121c0.562,0.563 1.325,0.879 2.121,0.879l4,0c0.552,0 1,-0.448 1,-1c-0,-0.552 -0.448,-1 -1,-1l-4,0c-0.265,-0 -0.52,-0.105 -0.707,-0.293c-0.188,-0.187 -0.293,-0.442 -0.293,-0.707l-0,-3l54,0l-0,3c0,0.265 -0.105,0.52 -0.293,0.707c-0.187,0.188 -0.442,0.293 -0.707,0.293l-44,0c-0.552,0 -1,0.448 -1,1c0,0.552 0.448,1 1,1l12.606,0c-0.16,2.682 -0.855,6.147 -3.417,8l-1.689,0c-0.552,-0 -1,0.448 -1,1c-0,0.552 0.448,1 1,1l21,0c0.552,-0 1,-0.448 1,-1c-0,-0.552 -0.448,-1 -1,-1l-1.689,0c-2.562,-1.854 -3.257,-5.318 -3.417,-8l20.606,0c0.796,-0 1.559,-0.316 2.121,-0.879c0.563,-0.562 0.879,-1.325 0.879,-2.121c-0,-6.028 -0,-23.972 -0,-30c0,-0.796 -0.316,-1.559 -0.879,-2.121c-0.562,-0.563 -1.325,-0.879 -2.121,-0.879l-11,0c-0.552,0 -1,0.448 -1,1c0,0.552 0.448,1 1,1l11,-0c0.265,0 0.52,0.105 0.707,0.293c0.188,0.187 0.293,0.442 0.293,0.707l-0,25Zm-23.606,8l-6.788,0c-0.155,2.531 -0.785,5.68 -2.585,8l11.958,0c-1.8,-2.32 -2.43,-5.47 -2.585,-8Zm-6.394,-9l6.5,0c0.552,0 1,-0.448 1,-1c0,-0.552 -0.448,-1 -1,-1l-6.5,0c-0.552,0 -1,0.448 -1,1c0,0.552 0.448,1 1,1Zm-22,-4l0,2c0,0.552 0.448,1 1,1c0.552,-0 1,-0.448 1,-1l0,-2c0,-0.552 -0.448,-1 -1,-1c-0.552,-0 -1,0.448 -1,1Zm20.5,1l9.5,0c0.552,-0 1,-0.448 1,-1c-0,-0.552 -0.448,-1 -1,-1l-9.5,0c-0.552,-0 -1,0.448 -1,1c-0,0.552 0.448,1 1,1Zm2.501,-3l-3.406,0c-0.261,0 -0.511,-0.102 -0.698,-0.284c-3.344,-3.257 -6.897,-8.272 -6.897,-14.756c0,-7.152 5.824,-12.96 13,-12.96c7.176,0 13,5.808 13,12.96c-0,6.484 -3.553,11.499 -6.897,14.756c-0.187,0.182 -0.437,0.284 -0.698,0.284l-3.405,0c0,0 -1,-0.003 -1,-1l0,-11l-2,0l0,10.999c0,0.652 -0.447,1.001 -0.999,1.001Zm-21.001,-1l0,-13c0,-0.552 -0.448,-1 -1,-1c-0.552,-0 -1,0.448 -1,1l0,13c0,0.552 0.448,1 1,1c0.552,-0 1,-0.448 1,-1Zm26,-1c0.871,-0 1.995,-0 1.995,-0c2.941,-2.949 6.005,-7.36 6.005,-13.04c0,-6.05 -4.93,-10.96 -11,-10.96c-6.07,0 -11,4.91 -11,10.96c-0,5.68 3.064,10.091 6.005,13.04l1.995,0l0,-10l-2,0c-2.208,0 -4,-1.792 -4,-4c0,-2.208 1.792,-4 4,-4c2.208,0 4,1.792 4,4l0,2l2,0l0,-2c0,-2.208 1.792,-4 4,-4c2.208,0 4,1.792 4,4c0,2.208 -1.792,4 -4,4l-2,0l0,10Zm-18,-19l-11,0c-0.796,0 -1.559,0.316 -2.121,0.879c-0.563,0.562 -0.879,1.325 -0.879,2.121c-0,2.509 -0,7.081 -0,12c-0,0.552 0.448,1 1,1c0.552,0 1,-0.448 1,-1l-0,-12c-0,-0.265 0.105,-0.52 0.293,-0.707c0.187,-0.188 0.442,-0.293 0.707,-0.293c0,0 11,0 11,0c0.552,0 1,-0.448 1,-1c0,-0.552 -0.448,-1 -1,-1Zm18,7l2,0c1.104,0 2,-0.896 2,-2c0,-1.104 -0.896,-2 -2,-2c-1.104,0 -2,0.896 -2,2l0,2Zm-6,0l0,-2c0,-1.104 -0.896,-2 -2,-2c-1.104,0 -2,0.896 -2,2c0,1.104 0.896,2 2,2l2,0Zm-2.509,-14.792l-1,-1.732c-0.276,-0.478 -0.888,-0.642 -1.366,-0.366c-0.478,0.276 -0.642,0.888 -0.366,1.366l1,1.732c0.276,0.478 0.888,0.642 1.366,0.366c0.478,-0.276 0.642,-0.888 0.366,-1.366Zm12.75,1l1,-1.732c0.276,-0.478 0.112,-1.09 -0.366,-1.366c-0.478,-0.276 -1.09,-0.112 -1.366,0.366l-1,1.732c-0.276,0.478 -0.112,1.09 0.366,1.366c0.478,0.276 1.09,0.112 1.366,-0.366Zm-6.241,-2.208l0,-2c0,-0.552 -0.448,-1 -1,-1c-0.552,-0 -1,0.448 -1,1l0,2c0,0.552 0.448,1 1,1c0.552,-0 1,-0.448 1,-1Z" fill="#FFFFFF" stroke="#FFFFFF" />
|
||||
|
||||
</ns0:g>
|
||||
|
||||
</ns0:svg>
|
||||
67
data/lang/en.json
Normal file
67
data/lang/en.json
Normal file
@ -0,0 +1,67 @@
|
||||
{
|
||||
"lang_name": "English",
|
||||
"language": "Language :",
|
||||
"theme": "Theme :",
|
||||
"dark_theme": "Dark Theme",
|
||||
"light_theme": "Light Theme",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"confirmation": "Confirmation",
|
||||
"information": "Information",
|
||||
"close": "Close",
|
||||
"suggestion_text": "Do you have a question or an idea to improve my software? Send me a message!",
|
||||
"suggestion_placeholder": "Type your message here...",
|
||||
"send_suggestion": "Send",
|
||||
"sending": "Sending...",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"suggestion_sent_success": "Your message has been sent successfully!",
|
||||
"suggestion_send_error": "Error sending message. Try again later.",
|
||||
"email_credentials_error": "Email credentials not or bad configured. Please set your email and password in the .env file.",
|
||||
"suggestion_too_short": "The message must be at least 15 characters long.",
|
||||
"update_found": "New version available: {latest_tag} \nDo you want to install the update?",
|
||||
"choose_update_folder": "Choose destination folder",
|
||||
"downloading_update": "Downloading update...",
|
||||
"update_downloaded": "Update downloaded to {local_path}",
|
||||
"update_download_error": "Error downloading update",
|
||||
"update": "Update",
|
||||
"version": "Version",
|
||||
"details": "Details",
|
||||
"update_details": "Update Details",
|
||||
"update_aborted": "Update aborted by user",
|
||||
"loading": "Loading...",
|
||||
"verifying_license": "Verifying license...",
|
||||
"checking_updates": "Checking for updates...",
|
||||
"initializing": "Initializing interface...",
|
||||
"loading_complete": "Loading complete",
|
||||
"activate_license": "Activate your license to unlock all features",
|
||||
"hardware_id_info": "This unique identifier is tied to your hardware. Share it with support if needed.",
|
||||
"hardware_id_copied": "Hardware ID copied to clipboard!",
|
||||
"license_key_section": "License Key :",
|
||||
"enter_license_key": "Enter your license key",
|
||||
"activate": "Activate",
|
||||
"buy_license": "Buy License",
|
||||
"comparaisons": "Version Comparison",
|
||||
"basic_version": "Basic Version",
|
||||
"premium_version": "Premium Version",
|
||||
"enterprise_version": "Enterprise Version",
|
||||
"support": "Support",
|
||||
"priority_support": "Priority support",
|
||||
"unlimited_key_uses": "Unlimited key uses",
|
||||
"license_active": "License active",
|
||||
"license_type": "Type",
|
||||
"license_email": "Email",
|
||||
"license_expires": "Expires on",
|
||||
"license_basic_mode": "Basic mode - Activate a license for more features",
|
||||
"invalid_license_key": "Invalid license key. It must contain at least 16 characters.",
|
||||
"premium_feature_message": "The feature '{feature}' requires a Premium or Enterprise license.",
|
||||
"upgrade_prompt": "Would you like to upgrade your license?",
|
||||
"buy_now": "Buy Now",
|
||||
"cancel": "Cancel",
|
||||
"activation_required": "Activation is required to continue.",
|
||||
"compare_versions": "Compare Versions",
|
||||
"no_license": "No License",
|
||||
"tab_suggestions": "Suggestions",
|
||||
"tab_settings": "Settings",
|
||||
"tab_licensing": "Licensing"
|
||||
}
|
||||
68
data/lang/fr.json
Normal file
68
data/lang/fr.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"lang_name": "Français",
|
||||
"language": "Langue :",
|
||||
"theme": "Thème :",
|
||||
"dark_theme": "Thème Sombre",
|
||||
"light_theme": "Thème Clair",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"confirmation": "Confirmation",
|
||||
"information": "Information",
|
||||
"close": "Fermer",
|
||||
"suggestion_text": "Vous avez une question ou une idée pour améliorer mon logiciel ? Envoyez-moi un message !",
|
||||
"suggestion_placeholder": "Tapez votre message ici...",
|
||||
"send_suggestion": "Envoyer",
|
||||
"sending": "Envoi...",
|
||||
"success": "Succès",
|
||||
"error": "Erreur",
|
||||
"suggestion_sent_success": "Votre message a été envoyé avec succès !",
|
||||
"suggestion_send_error": "Erreur lors de l'envoi du message. Essayez à nouveau plus tard.",
|
||||
"email_credentials_error": "Identifiants de messagerie non ou mal configurés. Veuillez définir votre email et mot de passe dans le fichier .env.",
|
||||
"suggestion_too_short": "Le message doit contenir au moins 15 caractères.",
|
||||
"update_found": "Nouvelle version disponible : {latest_tag} \nVoulez-vous installer la mise à jour ?",
|
||||
"choose_update_folder": "Choisissez le dossier de destination",
|
||||
"downloading_update": "Téléchargement de la mise à jour...",
|
||||
"update_downloaded": "Mise à jour téléchargée dans {local_path}",
|
||||
"update_download_error": "Erreur lors du téléchargement de la mise à jour",
|
||||
"update": "Mise à jour",
|
||||
"version": "Version",
|
||||
"details": "Détails",
|
||||
"update_details": "Détails de la mise à jour",
|
||||
"update_aborted": "Mise à jour annulée par l'utilisateur",
|
||||
"loading": "Chargement...",
|
||||
"verifying_license": "Vérification de la licence...",
|
||||
"checking_updates": "Vérification des mises à jour...",
|
||||
"initializing": "Initialisation de l'interface...",
|
||||
"loading_complete": "Chargement terminé",
|
||||
"activate_license": "Activez votre licence pour débloquer toutes les fonctionnalités",
|
||||
"hardware_id_info": "Cet identifiant unique est lié à votre matériel. Partagez-le avec le support si nécessaire.",
|
||||
"hardware_id_copied": "ID matériel copié dans le presse-papier !",
|
||||
"license_key_section": "Clé de licence :",
|
||||
"enter_license_key": "Entrez votre clé de licence",
|
||||
"activate": "Activer",
|
||||
"buy_license": "Acheter une licence",
|
||||
"comparaisons": "Comparaison des versions",
|
||||
"basic_version": "Version Basique",
|
||||
"premium_version": "Version Premium",
|
||||
"enterprise_version": "Version Enterprise",
|
||||
"support": "Support",
|
||||
"priority_support": "Support prioritaire",
|
||||
"unlimited_key_uses": "Utilisations de clé illimitées",
|
||||
"license_active": "Licence active",
|
||||
"license_type": "Type",
|
||||
"license_email": "Email",
|
||||
"license_expires": "Expire le",
|
||||
"license_basic_mode": "Mode basique - Activez une licence pour plus de fonctionnalités",
|
||||
"invalid_license_key": "Clé de licence invalide. Elle doit contenir au moins 16 caractères.",
|
||||
"premium_feature": "Fonctionnalité Premium",
|
||||
"premium_feature_message": "La fonctionnalité '{feature}' nécessite une licence Premium ou Enterprise.",
|
||||
"upgrade_prompt": "Souhaitez-vous mettre à niveau votre licence ?",
|
||||
"buy_now": "Acheter maintenant",
|
||||
"cancel": "Annuler",
|
||||
"activation_required": "L'activation est requise pour continuer.",
|
||||
"compare_versions": "Comparer les versions",
|
||||
"no_license": "Pas de licence",
|
||||
"tab_suggestions": "Suggestions",
|
||||
"tab_settings": "Paramètres",
|
||||
"tab_licensing": "Licence"
|
||||
}
|
||||
6
data/others/defaults_settings.json
Normal file
6
data/others/defaults_settings.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"theme": "dark",
|
||||
"lang": "fr",
|
||||
"window_size": {"width": 1000, "height": 600},
|
||||
"maximized": true
|
||||
}
|
||||
16
data/themes/dark.json
Normal file
16
data/themes/dark.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"theme_name": "dark",
|
||||
"colors": {
|
||||
"background_color": "#212121",
|
||||
"background_secondary_color": "#2C2C2E",
|
||||
"background_tertiary_color": "#4A4A4A",
|
||||
"border_color": "#3C3C3E",
|
||||
"text_color": "#D1D1D6",
|
||||
"primary_color": "#0A84FF",
|
||||
"primary_hover_color": "#007AFF",
|
||||
"icon_selected_color": "#D1D1D6",
|
||||
"icon_unselected_color": "#4A4A4A",
|
||||
"icon_selected_border_color": "#D1D1D6",
|
||||
"icon_hover_color": "#D1D1D6"
|
||||
}
|
||||
}
|
||||
16
data/themes/light.json
Normal file
16
data/themes/light.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"theme_name": "light",
|
||||
"colors": {
|
||||
"background_color": "#FFFFFF",
|
||||
"background_secondary_color": "#F5F5F5",
|
||||
"background_tertiary_color": "#E0E0E0",
|
||||
"border_color": "#1f1f20",
|
||||
"text_color": "#000000",
|
||||
"primary_color": "#0A84FF",
|
||||
"primary_hover_color": "#007AFF",
|
||||
"icon_selected_color": "#000000",
|
||||
"icon_unselected_color": "#5D5A5A",
|
||||
"icon_selected_border_color": "#000000",
|
||||
"icon_hover_color": "#000000"
|
||||
}
|
||||
}
|
||||
100
main.py
Normal file
100
main.py
Normal file
@ -0,0 +1,100 @@
|
||||
import sys
|
||||
import app.utils.paths as paths
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtGui import QIcon
|
||||
from app.ui.main_window import MainWindow
|
||||
from app.ui.windows.splash_screen import SplashScreen
|
||||
from app.core.main_manager import MainManager
|
||||
|
||||
preloaded_window = None
|
||||
|
||||
def preload_application(progress_callback, splash=None):
|
||||
"""
|
||||
Fonction de préchargement qui s'exécute pendant l'affichage du splash screen
|
||||
|
||||
Args:
|
||||
progress_callback: Fonction pour mettre à jour le texte de progression
|
||||
splash: Référence au splash screen (optionnel)
|
||||
|
||||
Returns:
|
||||
bool: True si tout s'est bien passé, False sinon
|
||||
"""
|
||||
global preloaded_window
|
||||
|
||||
try:
|
||||
main_manager = MainManager.get_instance()
|
||||
language_manager = main_manager.get_language_manager()
|
||||
update_manager = main_manager.get_update_manager()
|
||||
license_manager = main_manager.get_license_manager()
|
||||
settings_manager = main_manager.get_settings_manager()
|
||||
|
||||
# Vérifier la licence uniquement si le système est activé
|
||||
if settings_manager.get_config("enable_licensing"):
|
||||
progress_callback(language_manager.get_text("verifying_license"))
|
||||
license_manager.verify_license()
|
||||
|
||||
progress_callback(language_manager.get_text("checking_updates"))
|
||||
|
||||
if update_manager.check_for_update(splash, splash):
|
||||
return False
|
||||
|
||||
progress_callback(language_manager.get_text("initializing"))
|
||||
|
||||
preloaded_window = MainWindow()
|
||||
|
||||
progress_callback(language_manager.get_text("loading_complete"))
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during preload: {e}")
|
||||
return False
|
||||
|
||||
def main() -> int:
|
||||
global preloaded_window
|
||||
|
||||
main_manager: MainManager = MainManager.get_instance()
|
||||
theme_manager = main_manager.get_theme_manager()
|
||||
settings_manager = main_manager.get_settings_manager()
|
||||
|
||||
app: QApplication = QApplication(sys.argv)
|
||||
app.setStyleSheet(theme_manager.get_sheet())
|
||||
app.setApplicationName(settings_manager.get_config("app_name"))
|
||||
app.setWindowIcon(QIcon(paths.get_asset_path("icon")))
|
||||
|
||||
splash_image_path = paths.get_asset_path(settings_manager.get_config("splash_image"))
|
||||
use_splash = splash_image_path and paths.Path(splash_image_path).exists()
|
||||
|
||||
if use_splash:
|
||||
splash = SplashScreen(preload_function=lambda callback: preload_application(callback, splash))
|
||||
splash.show_splash()
|
||||
|
||||
def on_splash_finished(success):
|
||||
global preloaded_window
|
||||
if not success:
|
||||
app.quit()
|
||||
return
|
||||
|
||||
if preloaded_window:
|
||||
preloaded_window.show()
|
||||
else:
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
splash.finished.connect(on_splash_finished)
|
||||
else:
|
||||
|
||||
def dummy_progress(text):
|
||||
pass
|
||||
|
||||
success = preload_application(dummy_progress)
|
||||
if not success:
|
||||
return 0
|
||||
|
||||
window = preloaded_window if preloaded_window else MainWindow()
|
||||
window.show()
|
||||
|
||||
return app.exec()
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
PyQt6
|
||||
pyinstaller
|
||||
python-dotenv
|
||||
requests
|
||||
91
tools/build.bat
Normal file
91
tools/build.bat
Normal file
@ -0,0 +1,91 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM === PATH SETUP ===
|
||||
set PARENT_DIR=%~dp0..
|
||||
set CONFIG_FILE=%PARENT_DIR%\config.json
|
||||
set ICON_FILE=%PARENT_DIR%\data\assets\icon.png
|
||||
set ENV_FILE=%PARENT_DIR%\.env
|
||||
|
||||
REM Check if .env file exists
|
||||
if not exist "%ENV_FILE%" (
|
||||
echo [ERROR] .env file not found. Please copy .env.example to .env and configure it.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM === Extract values from config.json ===
|
||||
for /f "delims=" %%i in ('powershell -NoProfile -Command ^
|
||||
"Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty icon_path"') do set ICON_PATH=%%i
|
||||
for /f "delims=" %%i in ('powershell -NoProfile -Command ^
|
||||
"Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty app_name"') do set APP_NAME=%%i
|
||||
for /f "delims=" %%i in ('powershell -NoProfile -Command ^
|
||||
"Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty architecture"') do set ARCHITECTURE=%%i
|
||||
|
||||
REM === Extract python path from .env file ===
|
||||
for /f "usebackq tokens=2 delims==" %%i in (`findstr "PYTHON_PATH" "%ENV_FILE%"`) do set SYSTEM_PYTHON=%%i
|
||||
|
||||
set VENV_PATH=%PARENT_DIR%\WINenv_%ARCHITECTURE%
|
||||
set EXE_NAME=%APP_NAME%.exe
|
||||
set PYTHON_IN_VENV=%VENV_PATH%\Scripts\python.exe
|
||||
set BUILD_DIR=%PARENT_DIR%\build
|
||||
set ZIP_FILE=%BUILD_DIR%\%APP_NAME%.zip
|
||||
|
||||
REM === Verify Python existence ===
|
||||
if not exist "%SYSTEM_PYTHON%" (
|
||||
echo [ERROR] Python not found at: %SYSTEM_PYTHON%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM === Check if virtual environment exists ===
|
||||
if not exist "%VENV_PATH%\Scripts\activate.bat" (
|
||||
echo [INFO] Virtual environment not found. Creating...
|
||||
"%SYSTEM_PYTHON%" -m venv "%VENV_PATH%"
|
||||
"%PYTHON_IN_VENV%" -m pip install --upgrade pip
|
||||
"%PYTHON_IN_VENV%" -m pip install -r "%PARENT_DIR%\requirements.txt"
|
||||
) else (
|
||||
echo [INFO] Virtual environment found.
|
||||
)
|
||||
|
||||
REM === Run PyInstaller ===
|
||||
"%PYTHON_IN_VENV%" -m PyInstaller ^
|
||||
--distpath "%BUILD_DIR%" ^
|
||||
--workpath "%BUILD_DIR%\dist" ^
|
||||
--clean ^
|
||||
"%PARENT_DIR%\BUILD.spec"
|
||||
|
||||
REM === Clean build cache ===
|
||||
rmdir /s /q "%BUILD_DIR%\dist"
|
||||
|
||||
REM === Create ZIP ===
|
||||
echo [INFO] Creating ZIP archive...
|
||||
|
||||
set TEMP_ZIP_DIR=%BUILD_DIR%\temp_zip
|
||||
|
||||
REM Remove old temp dir if exists
|
||||
if exist "%TEMP_ZIP_DIR%" rmdir /s /q "%TEMP_ZIP_DIR%"
|
||||
mkdir "%TEMP_ZIP_DIR%"
|
||||
|
||||
REM Copy compiled app - tout le contenu du build sauf les ZIP existants
|
||||
move /Y "%BUILD_DIR%\%EXE_NAME%" "%TEMP_ZIP_DIR%\"
|
||||
|
||||
REM Copy config.json
|
||||
copy "%CONFIG_FILE%" "%TEMP_ZIP_DIR%\" /Y
|
||||
|
||||
REM Copy icon.png
|
||||
copy "%ICON_FILE%" "%TEMP_ZIP_DIR%\" /Y
|
||||
|
||||
REM Copy data/lang
|
||||
xcopy /E /I /Y "%PARENT_DIR%\data\lang" "%TEMP_ZIP_DIR%\lang"
|
||||
|
||||
REM Remove old ZIP if exists
|
||||
if exist "%ZIP_FILE%" del "%ZIP_FILE%"
|
||||
|
||||
REM Create ZIP
|
||||
powershell -NoProfile -Command "Add-Type -AssemblyName 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::CreateFromDirectory('%TEMP_ZIP_DIR%', '%ZIP_FILE%')"
|
||||
|
||||
REM Remove temp folder
|
||||
rmdir /s /q "%TEMP_ZIP_DIR%"
|
||||
|
||||
echo [INFO] ZIP created at: %ZIP_FILE%
|
||||
|
||||
endlocal
|
||||
53
tools/open.bat
Normal file
53
tools/open.bat
Normal file
@ -0,0 +1,53 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Set file paths
|
||||
set ROOT_DIR=%~dp0..
|
||||
set CONFIG_FILE=%ROOT_DIR%\config.json
|
||||
set REQUIREMENTS=%ROOT_DIR%\requirements.txt
|
||||
set ENV_FILE=%ROOT_DIR%\.env
|
||||
|
||||
REM Check if .env file exists
|
||||
if not exist "%ENV_FILE%" (
|
||||
echo [ERROR] .env file not found. Please copy .env.example to .env and configure it.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Extract config.json fields using PowerShell
|
||||
for /f "delims=" %%i in ('powershell -NoProfile -Command ^
|
||||
"Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty architecture"') do set ARCHITECTURE=%%i
|
||||
|
||||
REM Extract python path from .env file
|
||||
for /f "usebackq tokens=2 delims==" %%i in (`findstr "PYTHON_PATH" "%ENV_FILE%"`) do set PYTHON_EXEC=%%i
|
||||
|
||||
REM Construct python executable path
|
||||
set ENV_NAME=WINenv_%ARCHITECTURE%
|
||||
set ENV_PATH=%ROOT_DIR%\%ENV_NAME%
|
||||
|
||||
if not exist "%PYTHON_EXEC%" (
|
||||
echo [ERROR] Python introuvable à: %PYTHON_EXEC%
|
||||
echo Veuillez vérifier votre .env ou le dossier d'installation.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Show config info
|
||||
echo [INFO] Configuration :
|
||||
echo Python: %PYTHON_EXEC%
|
||||
echo Env: %ENV_NAME%
|
||||
|
||||
REM Check if virtual environment exists
|
||||
if not exist "%ENV_PATH%\Scripts" (
|
||||
echo [INFO] Environnement virtuel introuvable, création...
|
||||
"%PYTHON_EXEC%" -m venv "%ENV_PATH%"
|
||||
echo [INFO] Installation des dépendances...
|
||||
"%ENV_PATH%\Scripts\python" -m pip install --upgrade pip
|
||||
"%ENV_PATH%\Scripts\pip" install -r "%REQUIREMENTS%"
|
||||
) else (
|
||||
echo [INFO] Environnement virtuel trouvé.
|
||||
)
|
||||
REM Activate and launch VS Code
|
||||
echo [INFO] Lancement de VS Code...
|
||||
call "%ENV_PATH%/Scripts/activate.bat"
|
||||
start code "%ROOT_DIR%"
|
||||
|
||||
exit /b 0
|
||||
Loading…
x
Reference in New Issue
Block a user