From 88f32aed1f794c771dcefe2e0e1d7bbeb148fd78 Mon Sep 17 00:00:00 2001 From: LouisMazin Date: Sun, 21 Sep 2025 20:13:54 +0200 Subject: [PATCH] Initial commit --- .env.example | 8 + .gitignore | 89 +++++ BUILD.spec | 111 ++++++ LICENSE | 12 + README.md | 162 ++++++++ app/core/alert_manager.py | 80 ++++ app/core/language_manager.py | 40 ++ app/core/main_manager.py | 46 +++ app/core/observer_manager.py | 39 ++ app/core/settings_manager.py | 181 +++++++++ app/core/theme_manager.py | 158 ++++++++ app/core/update_manager.py | 195 ++++++++++ app/ui/main_window.py | 103 +++++ app/ui/widgets/loading_bar.py | 20 + app/ui/widgets/loading_spinner.py | 67 ++++ app/ui/widgets/tabs_widget.py | 560 ++++++++++++++++++++++++++++ app/ui/windows/settings_window.py | 101 +++++ app/ui/windows/splash_screen.py | 132 +++++++ app/ui/windows/suggestion_window.py | 137 +++++++ app/utils/paths.py | 57 +++ config.json | 10 + data/assets/icon.icns | Bin 0 -> 55740 bytes data/assets/icon.ico | Bin 0 -> 8591 bytes data/assets/icon.png | Bin 0 -> 16156 bytes data/assets/settings.svg | 1 + data/assets/splash.png | Bin 0 -> 54826 bytes data/assets/suggestion.svg | 9 + data/lang/en.json | 32 ++ data/lang/fr.json | 32 ++ data/others/defaults_settings.json | 6 + data/themes/dark.json | 16 + data/themes/light.json | 16 + main.py | 47 +++ requirements.txt | 4 + tools/build.bat | 56 +++ tools/build.command | 59 +++ tools/open.bat | 53 +++ tools/open.command | 59 +++ 38 files changed, 2698 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 BUILD.spec create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/core/alert_manager.py create mode 100644 app/core/language_manager.py create mode 100644 app/core/main_manager.py create mode 100644 app/core/observer_manager.py create mode 100644 app/core/settings_manager.py create mode 100644 app/core/theme_manager.py create mode 100644 app/core/update_manager.py create mode 100644 app/ui/main_window.py create mode 100644 app/ui/widgets/loading_bar.py create mode 100644 app/ui/widgets/loading_spinner.py create mode 100644 app/ui/widgets/tabs_widget.py create mode 100644 app/ui/windows/settings_window.py create mode 100644 app/ui/windows/splash_screen.py create mode 100644 app/ui/windows/suggestion_window.py create mode 100644 app/utils/paths.py create mode 100644 config.json create mode 100644 data/assets/icon.icns create mode 100644 data/assets/icon.ico create mode 100644 data/assets/icon.png create mode 100644 data/assets/settings.svg create mode 100644 data/assets/splash.png create mode 100644 data/assets/suggestion.svg create mode 100644 data/lang/en.json create mode 100644 data/lang/fr.json create mode 100644 data/others/defaults_settings.json create mode 100644 data/themes/dark.json create mode 100644 data/themes/light.json create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 tools/build.bat create mode 100644 tools/build.command create mode 100644 tools/open.bat create mode 100644 tools/open.command diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..16bd4e2 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Python Configuration +PYTHON_PATH=C:/Path/To/Your/Python/python.exe + +# Email configuration for suggestion system +EMAIL_ADDRESS=your_email@gmail.com +EMAIL_PASSWORD=your_app_password +EMAIL_SMTP_SERVER=smtp.gmail.com +EMAIL_SMTP_PORT=587 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..347c5af --- /dev/null +++ b/.gitignore @@ -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/ + diff --git a/BUILD.spec b/BUILD.spec new file mode 100644 index 0000000..4b02db2 --- /dev/null +++ b/BUILD.spec @@ -0,0 +1,111 @@ +# -*- 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 --- +version = config.get("app_version", "0.0.0") +arch = config.get("architecture", "x64") +python_version = config.get("python_version", "3.x") +os_name = config.get("app_os", sys.platform) +app_name = config.get("app_name", "Application") + +# --- Construct dynamic name --- +name = f"{app_name}-{os_name}-{arch}-v{version}" + +# --- 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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8367490 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Attribution License + +Copyright (c) 2025 LouisMazin + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +**Attribution Requirement:** +Any use, reproduction, or distribution of this Software, or derivative works thereof, must include a clear and visible attribution to the original author: LouisMazin. + +The above copyright notice, this permission notice, and the attribution requirement shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ca6260 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# 🚀 Python PyQt6 Application Template + +Bienvenue ! Ce projet est **bien plus qu'un simple template** : c'est une boîte à outils moderne pour créer rapidement des applications desktop Python élégantes, robustes et évolutives. +Vous voulez coder, personnaliser, traduire, mettre à jour, distribuer ? Tout est déjà prêt ! + +--- + +## ✨ Fonctionnalités clés + +- **Interface moderne (PyQt6)** : Responsive, stylée, et facile à personnaliser. +- **Thèmes dynamiques** : Passez du clair au sombre en un clic, ou créez le vôtre ! +- **Multi-langues** : Français, anglais... et ajoutez-en autant que vous voulez. +- **Paramètres utilisateurs** : Tout est sauvegardé (thème, langue, taille de fenêtre...). +- **Architecture modulaire** : Des managers pour chaque besoin, tout est organisé. +- **Notifications automatiques** : Les widgets s'adaptent instantanément aux changements. +- **Barre d’onglets flexible** : Ajoutez vos fenêtres où vous voulez, comme vous voulez. +- **Système de suggestion** : Vos utilisateurs peuvent vous écrire directement depuis l’app. +- **Mise à jour automatique** : Téléchargez la dernière version sans effort, avec barre de progression ! +- **Build & environnement** : Scripts pour tout automatiser, sur Windows, Linux, macOS. + +--- + +## 🗂️ Structure du projet + +``` +Template/ +├── app/ +│ ├── core/ # Managers (thème, langue, update, etc.) +│ ├── ui/ +│ │ ├── widgets/ # Onglets, loading bar, etc. +│ │ └── windows/ # Paramètres, suggestion... +│ └── utils/ # Fonctions utilitaires +├── data/ +│ ├── assets/ # Icônes, images +│ ├── lang/ # Traductions +│ ├── themes/ # Thèmes +│ └── others/ # Autres +├── tools/ # Scripts build/dev +├── config.json # Config principale +├── requirements.txt # Dépendances +├── BUILD.spec # PyInstaller +└── main.py # Point d’entrée +``` + +--- + +## ⚡ Démarrage Express + +1. **Configurez `config.json`** + (nom, version, OS, architecture, icône, dépôt git...) + +2. **Copiez `.env.example` → `.env` et configurez les variables requises** + - Depuis la racine du projet : + - Windows (PowerShell / cmd) : copy .env.example .env + - Linux / macOS : cp .env.example .env + - Au minimum, renseignez dans `.env` : + - PYTHON_PATH : chemin absolu vers votre exécutable Python (utilisé par tools/open.bat) + - les identifiants email si vous comptez utiliser l'envoi de suggestions (email + mot de passe / mot de passe d'application) + - Remarque : l'outil `tools/open.bat` s'appuie sur PYTHON_PATH ; sans cette variable, l'ouverture/initialisation de l'environnement échouera. + +3. **Lancez le dev** + - Windows : tools\open.bat (nécessite PYTHON_PATH dans `.env`) + - Linux : tools/open.sh (si présent / exécutable) + - macOS : tools/open.command (si présent / exécutable) + - Exécutez depuis leur fichier parent pour que les chemins relatifs fonctionnent correctement. + +4. **Build en un clic** + - Windows : tools\build.bat + - Linux : tools/build.sh + - macOS : tools/build.command + - Ces scripts supposent que `.env` est configuré et que les outils requis (pyinstaller, etc.) sont installés. + +--- + +## 🎨 Thèmes & 🌍 Langues + +- **Thèmes** : Ajoutez vos fichiers dans `data/themes/` (JSON). + Changez les couleurs, créez votre ambiance ! +- **Langues** : Ajoutez vos fichiers dans `data/lang/` (JSON). + Traduisez tout, c’est instantané. + +--- + +## 🧩 Managers & Architecture + +- **MainManager** : Le chef d’orchestre. +- **SettingsManager** : Les préférences utilisateur. +- **ThemeManager** : Les couleurs et le style. +- **LanguageManager** : Les textes traduits. +- **AlertManager** : Les messages, confirmations, erreurs. +- **UpdateManager** : Les mises à jour automatiques. +- **ObserverManager** : Les notifications internes. + +--- + +## 🔄 Mise à jour automatique + +- Vérifie la dernière version sur le dépôt Git à chaque démarrage. +- Propose la mise à jour si disponible. +- Télécharge le bon fichier selon votre OS/architecture. +- Affiche une barre de progression stylée. +- Lance la nouvelle version automatiquement ! + +--- + +## 💡 Suggestions & Feedback + +- Fenêtre dédiée pour envoyer vos idées ou questions par email. +- Sécurisé via `.env`. +- Gestion des erreurs/succès avec AlertManager. + +--- + +## 🛠️ Ajouter vos fenêtres & widgets + +- Créez une classe héritant de `QWidget`. +- Ajoutez-la dans la barre d’onglets (`TabsWidget`). +- Abonnez-vous aux notifications pour la langue/le thème. + +--- + +## 📦 Dépendances + +- **Python 3.10+ recommandé** (compatibilité testée avec 3.10/3.11). +- **PyQt6** : GUI moderne. +- **pyinstaller** : Build d’exécutables. +- **python-dotenv** : Variables d’environnement. +- **requests** : Requêtes HTTP (update). + +--- + +## 🔒 Sécurité + +- **Ne versionnez jamais `.env`** (déjà dans `.gitignore`). +- Utilisez un mot de passe d’application pour Gmail. + +--- + +## 📝 Licence + +Attribution License — voir le fichier local [`LICENSE`](https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate/src/branch/main/LICENSE) pour le texte complet. +Toute utilisation ou distribution doit inclure une attribution visible à l'auteur : LouisMazin. + +--- + +## 🤝 Contribution + +1. Forkez le dépôt +2. Créez une branche +3. Proposez vos modifications via pull request + +--- + +## 🆘 Support + +- Ouvrez une issue sur le dépôt +- Précisez votre OS, version Python, logs d’erreur + +--- + +**Ce template est fait pour vous faire gagner du temps et coder avec plaisir ! +Testez-le, améliorez-le, partagez-le 🚀** \ No newline at end of file diff --git a/app/core/alert_manager.py b/app/core/alert_manager.py new file mode 100644 index 0000000..c969486 --- /dev/null +++ b/app/core/alert_manager.py @@ -0,0 +1,80 @@ +from PyQt6.QtWidgets import QMessageBox +from typing import Optional + +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 \ No newline at end of file diff --git a/app/core/language_manager.py b/app/core/language_manager.py new file mode 100644 index 0000000..fe689ce --- /dev/null +++ b/app/core/language_manager.py @@ -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) diff --git a/app/core/main_manager.py b/app/core/main_manager.py new file mode 100644 index 0000000..439b5c4 --- /dev/null +++ b/app/core/main_manager.py @@ -0,0 +1,46 @@ +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 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) + + @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 \ No newline at end of file diff --git a/app/core/observer_manager.py b/app/core/observer_manager.py new file mode 100644 index 0000000..e1f8364 --- /dev/null +++ b/app/core/observer_manager.py @@ -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() diff --git a/app/core/settings_manager.py b/app/core/settings_manager.py new file mode 100644 index 0000000..c9e138b --- /dev/null +++ b/app/core/settings_manager.py @@ -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}") \ No newline at end of file diff --git a/app/core/theme_manager.py b/app/core/theme_manager.py new file mode 100644 index 0000000..6cd9f9f --- /dev/null +++ b/app/core/theme_manager.py @@ -0,0 +1,158 @@ +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 {{ + 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: 1px solid {self.current_theme.get_color("border_color")}; + border-radius: 8px; + padding: 10px; + font-size: 14px; + background-color: {self.current_theme.get_color("background_secondary_color")}; + color: {self.current_theme.get_color("text_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")}; + }} + 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")}; + }} + """ \ No newline at end of file diff --git a/app/core/update_manager.py b/app/core/update_manager.py new file mode 100644 index 0000000..3eba809 --- /dev/null +++ b/app/core/update_manager.py @@ -0,0 +1,195 @@ +import requests +from packaging import version +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QFileDialog, QDialog, QVBoxLayout, QTextEdit, QPushButton, QHBoxLayout, QLabel +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) -> 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): + 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) + 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() diff --git a/app/ui/main_window.py b/app/ui/main_window.py new file mode 100644 index 0000000..f5c21c4 --- /dev/null +++ b/app/ui/main_window.py @@ -0,0 +1,103 @@ +from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QLabel, QFrame +from PyQt6.QtGui import QResizeEvent, QCloseEvent +from PyQt6.QtCore import QSize, QEvent, Qt +from app.core.main_manager import MainManager, NotificationType +from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition, BorderSide, TabSide +from app.ui.windows.settings_window import SettingsWindow +from app.ui.windows.suggestion_window import SuggestionWindow +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.is_maximizing: bool = False + self.current_size: QSize + self.previous_size: QSize + + # UI elements + self.side_menu: TabsWidget + self.settings_window: SettingsWindow + self.suggestion_window: SuggestionWindow + self.footer_label: QLabel # Ajout d'un attribut pour le footer + + app: Optional[QApplication] = QApplication.instance() + size: QSize = app.primaryScreen().size() + self.settings_manager.minScreenSize = min(size.height(),size.width()) + + self.setMinimumSize(600, 400) + self.setup_ui() + self.apply_saved_window_state() + + 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() + + 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() + ) + + # Nettoyage des icônes temporaires générées (supprime tout le dossier temp_icons à la fermeture) + 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, 1, BorderSide.BOTTOM, TabSide.TOP) + + self.suggestion_window = SuggestionWindow(self) + self.side_menu.add_widget(self.suggestion_window, "", paths.get_asset_svg_path("suggestion"), position=ButtonPosition.CENTER) + + self.settings_window = SettingsWindow(self) + self.side_menu.add_widget(self.settings_window, "", paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER) + + self.setCentralWidget(self.side_menu) + + def update_theme(self) -> None: + self.setStyleSheet(self.theme_manager.get_sheet()) + + def update_language(self) -> None: + self.footer_label.setText(self.language_manager.get_text("footer_text")) \ No newline at end of file diff --git a/app/ui/widgets/loading_bar.py b/app/ui/widgets/loading_bar.py new file mode 100644 index 0000000..bfc61b3 --- /dev/null +++ b/app/ui/widgets/loading_bar.py @@ -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) diff --git a/app/ui/widgets/loading_spinner.py b/app/ui/widgets/loading_spinner.py new file mode 100644 index 0000000..dde26d6 --- /dev/null +++ b/app/ui/widgets/loading_spinner.py @@ -0,0 +1,67 @@ +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtWidgets import QLabel +from PyQt6.QtGui import QPainter, QPen +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): + from PyQt6.QtGui import QColor + 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() \ No newline at end of file diff --git a/app/ui/widgets/tabs_widget.py b/app/ui/widgets/tabs_widget.py new file mode 100644 index 0000000..6e3f9e8 --- /dev/null +++ b/app/ui/widgets/tabs_widget.py @@ -0,0 +1,560 @@ +from PyQt6.QtWidgets import QLayout, QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QStackedWidget, QSizePolicy, QSpacerItem +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import QSize +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 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, button_size_ratio=0.8, border_side=BorderSide.LEFT, tab_side=None): + 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.button_size_ratio = button_size_ratio # Default ratio for button size relative to menu width + self.onTabChange = onTabChange + + # 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_size_ratios = [] # Individual ratios 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, button_size_ratio=None): + """Add a widget with its corresponding button at specified position""" + # Create button + if icon_path: + colored_icon = self.apply_color_to_svg_icon(icon_path, self.unselected_icon_color) + button = QPushButton(colored_icon, button_text) + self._original_icon_paths.append(icon_path) + else: + button = QPushButton(button_text) + self._original_icon_paths.append(None) + + button.setCheckable(True) + + # Store button size ratio (use provided ratio or default) + ratio = button_size_ratio if button_size_ratio is not None else self.button_size_ratio + self.button_size_ratios.append(ratio) + + # Make button square with specified ratio + self._style_square_button(button) + + # Add to collections first + widget_index = len(self.widgets) + self.buttons.append(button) + self.widgets.append(widget) + self.button_positions.append(position) + + # Connect button to switch function + button.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, position, after_button_index) + + # Select first tab by default + if len(self.buttons) == 1: + self.switch_to_tab(0) + + return widget_index + + 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 + obj.setIcon(self.apply_color_to_svg_icon(self._original_icon_paths[self.buttons.index(obj)],self.hover_icon_color)) + elif event.type() == event.Type.Leave: + # Mouse left button + obj.setIcon(self.apply_color_to_svg_icon(self._original_icon_paths[self.buttons.index(obj)],self.unselected_icon_color if not obj.isChecked() else self.selected_icon_color)) + + 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 based on menu width and button's individual ratio""" + # Find the button's index to get its specific ratio + button_index = -1 + for i, btn in enumerate(self.buttons): + if btn == button: + button_index = i + break + + if button_index == -1: + return # Button not found + + # Get the specific ratio for this button + ratio = self.button_size_ratios[button_index] if button_index < len(self.button_size_ratios) else self.button_size_ratio + + button_size = int(self.menu_width * ratio) + button.setFixedSize(QSize(button_size, button_size)) + # Set proportional icon size + icon_size = int(button_size * 0.6) + button.setIconSize(QSize(icon_size, icon_size)) + + def _update_all_button_sizes(self): + """Update all button sizes when container is resized""" + if hasattr(self, '_square_buttons'): + for button in self._square_buttons: + if button.parent(): # Check if button still exists + self._update_button_size(button) + + 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): + """Set the button size ratio globally or for a specific button""" + if button_index is not None: + # Set ratio for specific button + if 0 <= button_index < len(self.button_size_ratios): + self.button_size_ratios[button_index] = ratio + if button_index < len(self.buttons): + self._update_button_size(self.buttons[button_index]) + else: + # Set global ratio + self.button_size_ratio = ratio + # Update all buttons that don't have individual ratios + for i in range(len(self.buttons)): + if i >= len(self.button_size_ratios): + self.button_size_ratios.append(ratio) + else: + self.button_size_ratios[i] = ratio + self._update_all_button_sizes() + + 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(self, '_original_icon_paths') 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) + button.setIcon(colored_icon) + + # 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) + + # Get original icon if it exists + original_icon = button.icon() + if not original_icon.isNull(): + # Apply color to SVG and create new icon + if hasattr(self, '_original_icon_paths') 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) + button.setIcon(colored_icon) + + # Apply button styling based on selection state + button.setProperty("selected", is_selected) + + # Force style update + button.style().unpolish(button) + button.style().polish(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 to a specific pixel size (overrides ratio-based sizing)""" + for button in self.buttons: + button.setFixedSize(size, size) + # Update icon size proportionally + icon_size = max(int(size * 0.6), 12) + button.setIconSize(QSize(icon_size, icon_size)) \ No newline at end of file diff --git a/app/ui/windows/settings_window.py b/app/ui/windows/settings_window.py new file mode 100644 index 0000000..50b8d13 --- /dev/null +++ b/app/ui/windows/settings_window.py @@ -0,0 +1,101 @@ +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.AlignTop) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + layout.addStretch() + + self.language_layout = QHBoxLayout() + # Paramètres de langue + self.languageLabel = QLabel(self.language_manager.get_text("language"),self) + self.languageLabel.setFixedWidth(120) # Largeur fixe pour l'alignement + self.language_layout.addWidget(self.languageLabel) + + self.languageCombo = self.createLanguageSelector() + self.language_layout.addWidget(self.languageCombo) + + layout.addLayout(self.language_layout) + + layout.addStretch() + + # Paramètres de thème + self.theme_layout = QHBoxLayout() + + self.themeLabel = QLabel(self.language_manager.get_text("theme"), self) + self.themeLabel.setFixedWidth(120) # Même largeur fixe pour l'alignement + self.theme_layout.addWidget(self.themeLabel) + + self.themeCombo = self.createThemeSelector() + self.theme_layout.addWidget(self.themeCombo) + + layout.addLayout(self.theme_layout) + + layout.addStretch() + + 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")) \ No newline at end of file diff --git a/app/ui/windows/splash_screen.py b/app/ui/windows/splash_screen.py new file mode 100644 index 0000000..3bcc02a --- /dev/null +++ b/app/ui/windows/splash_screen.py @@ -0,0 +1,132 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel +from PyQt6.QtCore import Qt, QTimer, pyqtSignal +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() + + def __init__(self, duration=3000, parent=None): + super().__init__(parent) + self.duration = duration + + 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.setup_ui() + self.setup_timer() + + + 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(800, 600) + + # 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) + + # 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 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""" + from PyQt6.QtWidgets import QApplication + 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 setup_timer(self): + """Configure le timer pour fermer automatiquement le splash screen""" + self.timer = QTimer() + self.timer.timeout.connect(self.close_splash) + self.timer.start(self.duration) + + def close_splash(self): + """Ferme le splash screen et émet le signal""" + if hasattr(self, 'spinner'): + self.spinner.stop() + self.timer.stop() + self.hide() + self.finished.emit() + + def show_splash(self): + """Affiche le splash screen""" + self.show() + self.raise_() + self.activateWindow() \ No newline at end of file diff --git a/app/ui/windows/suggestion_window.py b/app/ui/windows/suggestion_window.py new file mode 100644 index 0000000..c418e2d --- /dev/null +++ b/app/ui/windows/suggestion_window.py @@ -0,0 +1,137 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTextEdit, QPushButton, QLabel, QHBoxLayout +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.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.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;") + self.title_label.setWordWrap(True) # Permet le retour à la ligne automatique + self.title_label.setSizePolicy(self.title_label.sizePolicy().horizontalPolicy(), + self.title_label.sizePolicy().verticalPolicy()) + 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")) + layout.addWidget(self.text_edit) + + # 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")) + + # Create subject with app name + subject: str = f"Suggestion pour {self.settings_manager.get_config('app_name')}" + + # 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")) diff --git a/app/utils/paths.py b/app/utils/paths.py new file mode 100644 index 0000000..991026b --- /dev/null +++ b/app/utils/paths.py @@ -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") \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..2735aaa --- /dev/null +++ b/config.json @@ -0,0 +1,10 @@ +{ + "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" +} \ No newline at end of file diff --git a/data/assets/icon.icns b/data/assets/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..69fd9679720bc2be5b304d6d9f514a78069830e0 GIT binary patch literal 55740 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 bo+o4eknPCd_HtZ73#ck-Ud_F1`N#hO5j$v! literal 0 HcmV?d00001 diff --git a/data/assets/settings.svg b/data/assets/settings.svg new file mode 100644 index 0000000..b9b2280 --- /dev/null +++ b/data/assets/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/assets/splash.png b/data/assets/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..bdb7096dfd93d18cd289ea8baefdc99aa4c4438e GIT binary patch literal 54826 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 l1xqF+c6H#7^ + + + + + + + + \ No newline at end of file diff --git a/data/lang/en.json b/data/lang/en.json new file mode 100644 index 0000000..3d40ef6 --- /dev/null +++ b/data/lang/en.json @@ -0,0 +1,32 @@ +{ + "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 this application? 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" +} \ No newline at end of file diff --git a/data/lang/fr.json b/data/lang/fr.json new file mode 100644 index 0000000..cd713fc --- /dev/null +++ b/data/lang/fr.json @@ -0,0 +1,32 @@ +{ + "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 cette application ? 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" +} \ No newline at end of file diff --git a/data/others/defaults_settings.json b/data/others/defaults_settings.json new file mode 100644 index 0000000..f963074 --- /dev/null +++ b/data/others/defaults_settings.json @@ -0,0 +1,6 @@ +{ + "theme": "dark", + "lang": "fr", + "window_size": {"width": 1000, "height": 600}, + "maximized": true +} \ No newline at end of file diff --git a/data/themes/dark.json b/data/themes/dark.json new file mode 100644 index 0000000..4ee5cd8 --- /dev/null +++ b/data/themes/dark.json @@ -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" + } +} \ No newline at end of file diff --git a/data/themes/light.json b/data/themes/light.json new file mode 100644 index 0000000..4fe80b3 --- /dev/null +++ b/data/themes/light.json @@ -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" + } +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..29bf4bd --- /dev/null +++ b/main.py @@ -0,0 +1,47 @@ +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 + +def main() -> int: + main_manager: MainManager = MainManager.get_instance() + theme_manager = main_manager.get_theme_manager() + settings_manager = main_manager.get_settings_manager() + update_manager = main_manager.get_update_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"))) + + # Vérifier si l'image splash existe + 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() + + # Créer la fenêtre principale + window: MainWindow = MainWindow() + + if use_splash: + # Créer et afficher le splash screen + splash = SplashScreen(duration=1500) + splash.show_splash() + + # Connecter le signal finished pour afficher la fenêtre principale et vérifier les mises à jour + def show_main_window(): + if update_manager.check_for_update(): + return 0 + window.show() + + splash.finished.connect(lambda: show_main_window()) + else: + # Pas de splash screen, vérifier les mises à jour puis afficher la fenêtre principale + if update_manager.check_for_update(): + return 0 + window.show() + return app.exec() + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..390d828 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PyQt6 +pyinstaller +python-dotenv +requests \ No newline at end of file diff --git a/tools/build.bat b/tools/build.bat new file mode 100644 index 0000000..6d21702 --- /dev/null +++ b/tools/build.bat @@ -0,0 +1,56 @@ +@echo off +setlocal enabledelayedexpansion + +REM === PATH SETUP === +set PARENT_DIR=%~dp0.. +set CONFIG_FILE=%PARENT_DIR%\config.json +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%-Windows-%ARCHITECTURE% +set PYTHON_IN_VENV=%VENV_PATH%\Scripts\python.exe + +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 "%PARENT_DIR%\build" ^ + --workpath "%PARENT_DIR%\build\dist" ^ + --clean ^ + "%PARENT_DIR%\BUILD.spec" + +REM === Clean build cache === +rmdir /s /q "%PARENT_DIR%\build\dist" + +endlocal diff --git a/tools/build.command b/tools/build.command new file mode 100644 index 0000000..69841f9 --- /dev/null +++ b/tools/build.command @@ -0,0 +1,59 @@ +#!/bin/bash +set -e + +# === PATH SETUP === +PARENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIG_FILE="$PARENT_DIR/config.json" +ENV_FILE="$PARENT_DIR/.env" + +# --- Check .env file --- +if [[ ! -f "$ENV_FILE" ]]; then + echo "[ERROR] .env file not found. Please copy .env.example to .env and configure it." + exit 1 +fi + +# --- Check jq availability --- +if ! command -v jq &>/dev/null; then + echo "[ERROR] 'jq' is required. Install it with: brew install jq" + exit 1 +fi + +# --- Extract values from config.json --- +ICON_PATH=$(jq -r '.icon_path' "$CONFIG_FILE") +APP_NAME=$(jq -r '.app_name' "$CONFIG_FILE") +ARCHITECTURE=$(jq -r '.architecture' "$CONFIG_FILE") + +# --- Extract PYTHON_PATH from .env --- +SYSTEM_PYTHON=$(grep -E "^PYTHON_PATH=" "$ENV_FILE" | cut -d '=' -f2) + +VENV_PATH="$PARENT_DIR/MACenv_$ARCHITECTURE" +EXE_NAME="${APP_NAME}-MacOS-${ARCHITECTURE}" +PYTHON_IN_VENV="$VENV_PATH/bin/python" + +# --- Verify Python existence --- +if [[ ! -x "$SYSTEM_PYTHON" ]]; then + echo "[ERROR] Python not found at: $SYSTEM_PYTHON" + exit 1 +fi + +# --- Check if virtual environment exists --- +if [[ ! -f "$VENV_PATH/bin/activate" ]]; then + 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." +fi + +# --- Run PyInstaller --- +"$PYTHON_IN_VENV" -m PyInstaller \ + --distpath "$PARENT_DIR/build" \ + --workpath "$PARENT_DIR/build/dist" \ + --clean \ + "$PARENT_DIR/BUILD.spec" + +# --- Clean build cache --- +rm -rf "$PARENT_DIR/build/dist" + +echo "[INFO] Build complete: $EXE_NAME" \ No newline at end of file diff --git a/tools/open.bat b/tools/open.bat new file mode 100644 index 0000000..a6c4891 --- /dev/null +++ b/tools/open.bat @@ -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 diff --git a/tools/open.command b/tools/open.command new file mode 100644 index 0000000..675450f --- /dev/null +++ b/tools/open.command @@ -0,0 +1,59 @@ +#!/bin/bash +set -e + +# Root paths +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIG_FILE="$ROOT_DIR/config.json" +REQUIREMENTS="$ROOT_DIR/requirements.txt" +ENV_FILE="$ROOT_DIR/.env" + +# Check if .env exists +if [[ ! -f "$ENV_FILE" ]]; then + echo "[ERROR] .env file not found. Please copy .env.example to .env and configure it." + exit 1 +fi + +# Extract architecture from config.json (requires jq) +if ! command -v jq &>/dev/null; then + echo "[ERROR] 'jq' is required. Install it with: brew install jq" + exit 1 +fi +ARCHITECTURE=$(jq -r '.architecture' "$CONFIG_FILE") + +# Extract python path from .env +PYTHON_EXEC=$(grep -E "^PYTHON_PATH=" "$ENV_FILE" | cut -d '=' -f2) + +# Construct venv path +ENV_NAME="MACenv_$ARCHITECTURE" +ENV_PATH="$ROOT_DIR/$ENV_NAME" + +# Check python executable +if [[ ! -x "$PYTHON_EXEC" ]]; then + echo "[ERROR] Python not found at: $PYTHON_EXEC" + echo "Please check your .env or installation path." + exit 1 +fi + +# Show info +echo "[INFO] Configuration:" +echo " Python: $PYTHON_EXEC" +echo " Env: $ENV_NAME" + +# Create virtual env if missing +if [[ ! -d "$ENV_PATH/bin" ]]; then + echo "[INFO] Virtual environment not found, creating..." + "$PYTHON_EXEC" -m venv "$ENV_PATH" + echo "[INFO] Installing dependencies..." + "$ENV_PATH/bin/python" -m pip install --upgrade pip + "$ENV_PATH/bin/pip" install -r "$REQUIREMENTS" +else + echo "[INFO] Virtual environment found." +fi + +# Activate and launch VS Code +echo "[INFO] Launching VS Code..." +# shellcheck source=/dev/null +source "$ENV_PATH/bin/activate" +open -a "Visual Studio Code" "$ROOT_DIR" + +exit 0 \ No newline at end of file