Initial commit

This commit is contained in:
LouisMazin 2025-09-21 20:13:54 +02:00
commit 88f32aed1f
38 changed files with 2698 additions and 0 deletions

8
.env.example Normal file
View File

@ -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

89
.gitignore vendored Normal file
View File

@ -0,0 +1,89 @@
# Fichiers système
.DS_Store
Thumbs.db
desktop.ini
# Dossiers de build et distributions
/build/
/dist/
/out/
/release/
*.exe
*.msi
*.dmg
*.pkg
*.app
# Fichiers d'environnement et de configuration
.env
.env.local
.env.development
.env.test
.env.production
LINenv*/
WINenv*/
MACenv*/
# Fichiers de dépendances
/node_modules/
/.pnp/
.pnp.js
/vendor/
/packages/
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Logs et bases de données
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.sqlite
*.db
# Fichiers d'IDE et d'éditeurs
.idea/
.vscode/
*.swp
*.swo
*.sublime-workspace
*.sublime-project
.vs/
*.user
*.userosscache
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Fichiers temporaires
/tmp/
/temp/
*.tmp
*.bak
*~
# Fichiers spécifiques à l'application
# Ne pas ignorer le fichier config.json
!config.json
# Dossier de cache
.cache/
.parcel-cache/
# Fichiers de couverture de test
coverage/
.nyc_output/
.coverage
htmlcov/
# Autres
.vercel
.next
.nuxt
.serverless/

111
BUILD.spec Normal file
View File

@ -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

12
LICENSE Normal file
View File

@ -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.

162
README.md Normal file
View File

@ -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 donglets flexible** : Ajoutez vos fenêtres où vous voulez, comme vous voulez.
- **Système de suggestion** : Vos utilisateurs peuvent vous écrire directement depuis lapp.
- **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 dentré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, cest instantané.
---
## 🧩 Managers & Architecture
- **MainManager** : Le chef dorchestre.
- **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 donglets (`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 dexécutables.
- **python-dotenv** : Variables denvironnement.
- **requests** : Requêtes HTTP (update).
---
## 🔒 Sécurité
- **Ne versionnez jamais `.env`** (déjà dans `.gitignore`).
- Utilisez un mot de passe dapplication 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 derreur
---
**Ce template est fait pour vous faire gagner du temps et coder avec plaisir !
Testez-le, améliorez-le, partagez-le 🚀**

80
app/core/alert_manager.py Normal file
View File

@ -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

View File

@ -0,0 +1,40 @@
from json import JSONDecodeError, load
from os import listdir, path
from typing import Dict
import app.utils.paths as paths
from app.core.settings_manager import SettingsManager
class LanguageManager:
"""
Gère les traductions pour l'application.
Charge les fichiers JSON de langue dans data/lang/, permet
de changer la langue courante, et récupérer des textes traduits.
"""
def __init__(self, settings_manager: SettingsManager) -> None:
self.translations: Dict[str, Dict[str, str]] = {}
self.settings_manager: SettingsManager = settings_manager
self.load_all_translations()
def load_all_translations(self) -> None:
"""
Charge tous les fichiers JSON dans data/lang/ comme dictionnaires.
"""
lang_dir: str = paths.get_lang_path()
for filename in listdir(lang_dir):
if filename.endswith(".json"):
lang_code: str = filename[:-5] # strip .json
file_path: str = path.join(lang_dir, filename)
try:
with open(file_path, "r", encoding="utf-8") as f:
self.translations[lang_code] = load(f)
except JSONDecodeError as e:
pass
def get_text(self, key: str) -> str:
"""
Retourne le texte traduit correspondant à la clé dans la langue courante.
Si la clé n'existe pas, retourne la clé elle-même.
"""
return self.translations.get(self.settings_manager.get_language(), {}).get(key, key)

46
app/core/main_manager.py Normal file
View File

@ -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

View File

@ -0,0 +1,39 @@
from typing import Callable, Dict, List, Any
class NotificationType:
THEME = 0
LANGUAGE = 1
class ObserverManager:
"""
Gestionnaire d'observateurs pour gérer les événements et notifier les abonnés.
Chaque événement est identifié par une clé (string), et les abonnés sont des callbacks.
"""
def __init__(self):
self.observers: Dict[str, List[Callable[[Any], None]]] = {}
def subscribe(self, event: str, callback: Callable[[Any], None]) -> None:
"""
Ajoute un callback à la liste des abonnés pour l'événement donné.
"""
if event not in self.observers:
self.observers[event] = []
if callback not in self.observers[event]:
self.observers[event].append(callback)
def unsubscribe(self, event: str, callback: Callable[[Any], None]) -> None:
"""
Retire un callback de la liste des abonnés pour l'événement donné.
"""
if event in self.observers and callback in self.observers[event]:
self.observers[event].remove(callback)
if not self.observers[event]:
del self.observers[event]
def notify(self, event: NotificationType) -> None:
"""
Notifie tous les abonnés de l'événement donné, en leur passant la donnée.
"""
if event in self.observers:
for callback in self.observers[event]:
callback()

View File

@ -0,0 +1,181 @@
from PyQt6.QtCore import QSettings
from app.core.observer_manager import ObserverManager, NotificationType
from app.core.theme_manager import ThemeManager
from os import path
import app.utils.paths as paths
import json
import logging
from typing import Dict, Any
# Configure logging
logger: logging.Logger = logging.getLogger(__name__)
class SettingsManager:
"""
Gestion des paramètres utilisateurs avec sauvegarde persistante via QSettings.
Notifie les changements via ObserverManager.
"""
def __init__(self, observer_manager: ObserverManager, theme_manager: ThemeManager) -> None:
self.observer_manager: ObserverManager = observer_manager
self.theme_manager: ThemeManager = theme_manager
# Load default settings
self.default_settings: Dict[str, Any] = self._load_default_settings()
# Load config
self.config: Dict[str, Any] = self._load_config()
# Initialize QSettings with error handling
self.settings: QSettings = self._initialize_qsettings()
# Set initial theme
try:
self.theme_manager.set_theme(self.get_theme())
except Exception as e:
logger.error(f"Error setting initial theme: {e}")
self.theme_manager.set_theme(self.fallback_settings["theme"])
def _load_default_settings(self) -> Dict[str, Any]:
"""Load default settings with error handling"""
try:
defaults_path = path.join(paths.get_data_dir(), "others", "defaults_settings.json")
if path.exists(defaults_path):
with open(defaults_path, 'r', encoding='utf-8') as f:
settings = json.load(f)
return settings
else:
logger.warning(f"defaults_settings.json not found")
return {}
except (json.JSONDecodeError, FileNotFoundError, KeyError, UnicodeDecodeError) as e:
logger.error(f"Error loading default settings: {e}")
return {}
except Exception as e:
logger.error(f"Unexpected error loading default settings: {e}")
return {}
def _load_config(self) -> Dict[str, Any]:
"""Load config.json with error handling"""
try:
config_path = paths.resource_path("config.json")
if path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
return config
else:
logger.warning("config.json not found")
return {}
except (json.JSONDecodeError, FileNotFoundError, KeyError, UnicodeDecodeError) as e:
logger.error(f"Error loading config: {e}")
return {}
except Exception as e:
logger.error(f"Unexpected error loading config: {e}")
return {}
def _initialize_qsettings(self) -> QSettings:
"""Initialize QSettings with error handling"""
try:
app_name = self.get_config("app_name")
user_data_dir = paths.get_user_data_dir(app_name)
settings_path = path.join(user_data_dir, f"{app_name}.ini")
return QSettings(settings_path, QSettings.Format.IniFormat)
except Exception as e:
logger.error(f"Error initializing QSettings: {e}")
# Fallback to memory-only settings
return QSettings()
# Config
def get_config(self, key: str) -> Any:
"""Get configuration value by key"""
try:
return self.config.get(key)
except Exception as e:
logger.error(f"Error getting config key '{key}': {e}")
return None
# Theme
def get_theme(self) -> str:
"""Get the current theme"""
try:
return self.settings.value("theme", self.default_settings.get("theme"))
except Exception as e:
logger.error(f"Error getting theme: {e}")
return ""
def set_theme(self, mode: str) -> None:
"""Set the application theme"""
try:
if mode != self.get_theme():
self.settings.setValue("theme", mode)
self.theme_manager.set_theme(mode)
self.observer_manager.notify(NotificationType.THEME)
except Exception as e:
logger.error(f"Error setting theme: {e}")
# Language
def get_language(self) -> str:
"""Get the current language"""
try:
return self.settings.value("lang", self.default_settings.get("lang"))
except Exception as e:
logger.error(f"Error getting language: {e}")
return ""
def set_language(self, lang_code: str) -> None:
"""Set the application language"""
try:
if lang_code != self.get_language():
self.settings.setValue("lang", lang_code)
self.observer_manager.notify(NotificationType.LANGUAGE)
except Exception as e:
logger.error(f"Error setting language: {e}")
# Window size and maximized
def get_window_size(self) -> Dict[str, int]:
"""Get the window size"""
try:
size = self.settings.value("window_size", self.default_settings.get("window_size"))
# Validate window size values
if isinstance(size, dict) and "width" in size and "height" in size:
width = int(size["width"]) if size["width"] > 0 else 1000
height = int(size["height"]) if size["height"] > 0 else 600
return {"width": width, "height": height}
else:
return {"width": 1000, "height": 600}
except Exception as e:
logger.error(f"Error getting window size: {e}")
return {"width": 1000, "height": 600}
def set_window_size(self, width: int, height: int) -> None:
"""Set the window size"""
try:
# Validate input values
if width <= 0 or height <= 0:
logger.warning(f"Invalid window size: {width}x{height}")
return
current_size = self.get_window_size()
if current_size["width"] != width or current_size["height"] != height:
new_size = {"width": width, "height": height}
self.settings.setValue("window_size", new_size)
except Exception as e:
logger.error(f"Error setting window size: {e}")
def get_maximized(self) -> bool:
"""Check if the window is maximized"""
try:
value = self.settings.value("maximized", self.default_settings.get("maximized"))
if isinstance(value, str):
return value.lower() == "true"
elif isinstance(value, bool):
return value
else:
return False
except Exception as e:
logger.error(f"Error getting maximized state: {e}")
return False
def set_maximized(self, maximized: bool) -> None:
"""Set the window maximized state"""
try:
self.settings.setValue("maximized", maximized)
except Exception as e:
logger.error(f"Error setting maximized state: {e}")

158
app/core/theme_manager.py Normal file
View File

@ -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")};
}}
"""

195
app/core/update_manager.py Normal file
View File

@ -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()

103
app/ui/main_window.py Normal file
View File

@ -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"))

View File

@ -0,0 +1,20 @@
from PyQt6.QtWidgets import QProgressBar, QWidget, QVBoxLayout, QLabel
from PyQt6.QtCore import Qt
class LoadingBar(QWidget):
def __init__(self, label_text: str = "", parent=None) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.label = QLabel(label_text, self)
self.progress = QProgressBar(self)
self.progress.setMinimum(0)
self.progress.setMaximum(100)
layout.addWidget(self.label)
layout.addWidget(self.progress)
def set_label(self, text: str) -> None:
self.label.setText(text)
def set_progress(self, value: int) -> None:
self.progress.setValue(value)

View File

@ -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()

View File

@ -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))

View File

@ -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"))

View File

@ -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()

View File

@ -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"))

57
app/utils/paths.py Normal file
View File

@ -0,0 +1,57 @@
from os import path, getenv, mkdir
import sys
from platform import system
from pathlib import Path
from typing import Optional
def resource_path(relative_path: str) -> str:
"""
Get absolute path to resource, works for dev and for PyInstaller bundle.
PyInstaller stores bundled files in _MEIPASS folder.
"""
try:
base_path: Path = Path(sys._MEIPASS) # PyInstaller temp folder
except AttributeError:
base_path: Path = Path(__file__).parent.parent.parent # Dev environment: source/ folder
return path.join(base_path, relative_path)
def get_data_dir() -> str:
return resource_path("data")
def get_lang_path() -> str:
return path.join(get_data_dir(), "lang")
def get_asset_path(asset: str) -> str:
return path.join(get_data_dir(), "assets", f"{asset}.png")
def get_asset_svg_path(asset: str) -> str:
return path.join(get_data_dir(), "assets", f"{asset}.svg")
def get_user_data_dir(app_name: str) -> str:
home: Path = Path.home()
os: str = system()
if os == "Windows":
appdata: Optional[str] = getenv('APPDATA')
if appdata:
user_data_path: str = path.join(Path(appdata), app_name)
else:
user_data_path: str = path.join(home, "AppData", "Roaming", app_name)
elif os == "Darwin":
user_data_path: str = path.join(home, "Library", "Application Support", app_name)
else:
user_data_path: str = path.join(home, ".local", "share", app_name)
if not path.exists(user_data_path):
mkdir(user_data_path)
return user_data_path
def get_user_temp_icons(app_name: str) -> str:
user_data_path = get_user_data_dir(app_name)
return path.join(user_data_path, "temp", "icons")
def get_user_temp(app_name: str) -> str:
user_data_path = get_user_data_dir(app_name)
return path.join(user_data_path, "temp")

10
config.json Normal file
View File

@ -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"
}

BIN
data/assets/icon.icns Normal file

Binary file not shown.

BIN
data/assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
data/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

1
data/assets/settings.svg Normal file
View File

@ -0,0 +1 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="100px" height="100px"><ns0:path d="M 22.205078 2 A 1.0001 1.0001 0 0 0 21.21875 2.8378906 L 20.246094 8.7929688 C 19.076509 9.1331971 17.961243 9.5922728 16.910156 10.164062 L 11.996094 6.6542969 A 1.0001 1.0001 0 0 0 10.708984 6.7597656 L 6.8183594 10.646484 A 1.0001 1.0001 0 0 0 6.7070312 11.927734 L 10.164062 16.873047 C 9.583454 17.930271 9.1142098 19.051824 8.765625 20.232422 L 2.8359375 21.21875 A 1.0001 1.0001 0 0 0 2.0019531 22.205078 L 2.0019531 27.705078 A 1.0001 1.0001 0 0 0 2.8261719 28.691406 L 8.7597656 29.742188 C 9.1064607 30.920739 9.5727226 32.043065 10.154297 33.101562 L 6.6542969 37.998047 A 1.0001 1.0001 0 0 0 6.7597656 39.285156 L 10.648438 43.175781 A 1.0001 1.0001 0 0 0 11.927734 43.289062 L 16.882812 39.820312 C 17.936999 40.39548 19.054994 40.857928 20.228516 41.201172 L 21.21875 47.164062 A 1.0001 1.0001 0 0 0 22.205078 48 L 27.705078 48 A 1.0001 1.0001 0 0 0 28.691406 47.173828 L 29.751953 41.1875 C 30.920633 40.838997 32.033372 40.369697 33.082031 39.791016 L 38.070312 43.291016 A 1.0001 1.0001 0 0 0 39.351562 43.179688 L 43.240234 39.287109 A 1.0001 1.0001 0 0 0 43.34375 37.996094 L 39.787109 33.058594 C 40.355783 32.014958 40.813915 30.908875 41.154297 29.748047 L 47.171875 28.693359 A 1.0001 1.0001 0 0 0 47.998047 27.707031 L 47.998047 22.207031 A 1.0001 1.0001 0 0 0 47.160156 21.220703 L 41.152344 20.238281 C 40.80968 19.078827 40.350281 17.974723 39.78125 16.931641 L 43.289062 11.933594 A 1.0001 1.0001 0 0 0 43.177734 10.652344 L 39.287109 6.7636719 A 1.0001 1.0001 0 0 0 37.996094 6.6601562 L 33.072266 10.201172 C 32.023186 9.6248101 30.909713 9.1579916 29.738281 8.8125 L 28.691406 2.828125 A 1.0001 1.0001 0 0 0 27.705078 2 L 22.205078 2 z M 23.056641 4 L 26.865234 4 L 27.861328 9.6855469 A 1.0001 1.0001 0 0 0 28.603516 10.484375 C 30.066026 10.848832 31.439607 11.426549 32.693359 12.185547 A 1.0001 1.0001 0 0 0 33.794922 12.142578 L 38.474609 8.7792969 L 41.167969 11.472656 L 37.835938 16.220703 A 1.0001 1.0001 0 0 0 37.796875 17.310547 C 38.548366 18.561471 39.118333 19.926379 39.482422 21.380859 A 1.0001 1.0001 0 0 0 40.291016 22.125 L 45.998047 23.058594 L 45.998047 26.867188 L 40.279297 27.871094 A 1.0001 1.0001 0 0 0 39.482422 28.617188 C 39.122545 30.069817 38.552234 31.434687 37.800781 32.685547 A 1.0001 1.0001 0 0 0 37.845703 33.785156 L 41.224609 38.474609 L 38.53125 41.169922 L 33.791016 37.84375 A 1.0001 1.0001 0 0 0 32.697266 37.808594 C 31.44975 38.567585 30.074755 39.148028 28.617188 39.517578 A 1.0001 1.0001 0 0 0 27.876953 40.3125 L 26.867188 46 L 23.052734 46 L 22.111328 40.337891 A 1.0001 1.0001 0 0 0 21.365234 39.53125 C 19.90185 39.170557 18.522094 38.59371 17.259766 37.835938 A 1.0001 1.0001 0 0 0 16.171875 37.875 L 11.46875 41.169922 L 8.7734375 38.470703 L 12.097656 33.824219 A 1.0001 1.0001 0 0 0 12.138672 32.724609 C 11.372652 31.458855 10.793319 30.079213 10.427734 28.609375 A 1.0001 1.0001 0 0 0 9.6328125 27.867188 L 4.0019531 26.867188 L 4.0019531 23.052734 L 9.6289062 22.117188 A 1.0001 1.0001 0 0 0 10.435547 21.373047 C 10.804273 19.898143 11.383325 18.518729 12.146484 17.255859 A 1.0001 1.0001 0 0 0 12.111328 16.164062 L 8.8261719 11.46875 L 11.523438 8.7734375 L 16.185547 12.105469 A 1.0001 1.0001 0 0 0 17.28125 12.148438 C 18.536908 11.394293 19.919867 10.822081 21.384766 10.462891 A 1.0001 1.0001 0 0 0 22.132812 9.6523438 L 23.056641 4 z M 25 17 C 20.593567 17 17 20.593567 17 25 C 17 29.406433 20.593567 33 25 33 C 29.406433 33 33 29.406433 33 25 C 33 20.593567 29.406433 17 25 17 z M 25 19 C 28.325553 19 31 21.674447 31 25 C 31 28.325553 28.325553 31 25 31 C 21.674447 31 19 28.325553 19 25 C 19 21.674447 21.674447 19 25 19 z" fill="#D1D1D6"/></ns0:svg>

BIN
data/assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -0,0 +1,9 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="0 0 64 64" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve">
<ns0:g id="ICON">
<ns0:path d="M59,43l-54,0l-0,-9c-0,-0.552 -0.448,-1 -1,-1c-0.552,-0 -1,0.448 -1,1c-0,5.651 -0,11.154 -0,14c-0,0.796 0.316,1.559 0.879,2.121c0.562,0.563 1.325,0.879 2.121,0.879l4,0c0.552,0 1,-0.448 1,-1c-0,-0.552 -0.448,-1 -1,-1l-4,0c-0.265,-0 -0.52,-0.105 -0.707,-0.293c-0.188,-0.187 -0.293,-0.442 -0.293,-0.707l-0,-3l54,0l-0,3c0,0.265 -0.105,0.52 -0.293,0.707c-0.187,0.188 -0.442,0.293 -0.707,0.293l-44,0c-0.552,0 -1,0.448 -1,1c0,0.552 0.448,1 1,1l12.606,0c-0.16,2.682 -0.855,6.147 -3.417,8l-1.689,0c-0.552,-0 -1,0.448 -1,1c-0,0.552 0.448,1 1,1l21,0c0.552,-0 1,-0.448 1,-1c-0,-0.552 -0.448,-1 -1,-1l-1.689,0c-2.562,-1.854 -3.257,-5.318 -3.417,-8l20.606,0c0.796,-0 1.559,-0.316 2.121,-0.879c0.563,-0.562 0.879,-1.325 0.879,-2.121c-0,-6.028 -0,-23.972 -0,-30c0,-0.796 -0.316,-1.559 -0.879,-2.121c-0.562,-0.563 -1.325,-0.879 -2.121,-0.879l-11,0c-0.552,0 -1,0.448 -1,1c0,0.552 0.448,1 1,1l11,-0c0.265,0 0.52,0.105 0.707,0.293c0.188,0.187 0.293,0.442 0.293,0.707l-0,25Zm-23.606,8l-6.788,0c-0.155,2.531 -0.785,5.68 -2.585,8l11.958,0c-1.8,-2.32 -2.43,-5.47 -2.585,-8Zm-6.394,-9l6.5,0c0.552,0 1,-0.448 1,-1c0,-0.552 -0.448,-1 -1,-1l-6.5,0c-0.552,0 -1,0.448 -1,1c0,0.552 0.448,1 1,1Zm-22,-4l0,2c0,0.552 0.448,1 1,1c0.552,-0 1,-0.448 1,-1l0,-2c0,-0.552 -0.448,-1 -1,-1c-0.552,-0 -1,0.448 -1,1Zm20.5,1l9.5,0c0.552,-0 1,-0.448 1,-1c-0,-0.552 -0.448,-1 -1,-1l-9.5,0c-0.552,-0 -1,0.448 -1,1c-0,0.552 0.448,1 1,1Zm2.501,-3l-3.406,0c-0.261,0 -0.511,-0.102 -0.698,-0.284c-3.344,-3.257 -6.897,-8.272 -6.897,-14.756c0,-7.152 5.824,-12.96 13,-12.96c7.176,0 13,5.808 13,12.96c-0,6.484 -3.553,11.499 -6.897,14.756c-0.187,0.182 -0.437,0.284 -0.698,0.284l-3.405,0c0,0 -1,-0.003 -1,-1l0,-11l-2,0l0,10.999c0,0.652 -0.447,1.001 -0.999,1.001Zm-21.001,-1l0,-13c0,-0.552 -0.448,-1 -1,-1c-0.552,-0 -1,0.448 -1,1l0,13c0,0.552 0.448,1 1,1c0.552,-0 1,-0.448 1,-1Zm26,-1c0.871,-0 1.995,-0 1.995,-0c2.941,-2.949 6.005,-7.36 6.005,-13.04c0,-6.05 -4.93,-10.96 -11,-10.96c-6.07,0 -11,4.91 -11,10.96c-0,5.68 3.064,10.091 6.005,13.04l1.995,0l0,-10l-2,0c-2.208,0 -4,-1.792 -4,-4c0,-2.208 1.792,-4 4,-4c2.208,0 4,1.792 4,4l0,2l2,0l0,-2c0,-2.208 1.792,-4 4,-4c2.208,0 4,1.792 4,4c0,2.208 -1.792,4 -4,4l-2,0l0,10Zm-18,-19l-11,0c-0.796,0 -1.559,0.316 -2.121,0.879c-0.563,0.562 -0.879,1.325 -0.879,2.121c-0,2.509 -0,7.081 -0,12c-0,0.552 0.448,1 1,1c0.552,0 1,-0.448 1,-1l-0,-12c-0,-0.265 0.105,-0.52 0.293,-0.707c0.187,-0.188 0.442,-0.293 0.707,-0.293c0,0 11,0 11,0c0.552,0 1,-0.448 1,-1c0,-0.552 -0.448,-1 -1,-1Zm18,7l2,0c1.104,0 2,-0.896 2,-2c0,-1.104 -0.896,-2 -2,-2c-1.104,0 -2,0.896 -2,2l0,2Zm-6,0l0,-2c0,-1.104 -0.896,-2 -2,-2c-1.104,0 -2,0.896 -2,2c0,1.104 0.896,2 2,2l2,0Zm-2.509,-14.792l-1,-1.732c-0.276,-0.478 -0.888,-0.642 -1.366,-0.366c-0.478,0.276 -0.642,0.888 -0.366,1.366l1,1.732c0.276,0.478 0.888,0.642 1.366,0.366c0.478,-0.276 0.642,-0.888 0.366,-1.366Zm12.75,1l1,-1.732c0.276,-0.478 0.112,-1.09 -0.366,-1.366c-0.478,-0.276 -1.09,-0.112 -1.366,0.366l-1,1.732c-0.276,0.478 -0.112,1.09 0.366,1.366c0.478,0.276 1.09,0.112 1.366,-0.366Zm-6.241,-2.208l0,-2c0,-0.552 -0.448,-1 -1,-1c-0.552,-0 -1,0.448 -1,1l0,2c0,0.552 0.448,1 1,1c0.552,-0 1,-0.448 1,-1Z" fill="#FFFFFF" stroke="#FFFFFF" />
</ns0:g>
</ns0:svg>

32
data/lang/en.json Normal file
View File

@ -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"
}

32
data/lang/fr.json Normal file
View File

@ -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"
}

View File

@ -0,0 +1,6 @@
{
"theme": "dark",
"lang": "fr",
"window_size": {"width": 1000, "height": 600},
"maximized": true
}

16
data/themes/dark.json Normal file
View File

@ -0,0 +1,16 @@
{
"theme_name": "dark",
"colors": {
"background_color": "#212121",
"background_secondary_color": "#2C2C2E",
"background_tertiary_color": "#4A4A4A",
"border_color": "#3C3C3E",
"text_color": "#D1D1D6",
"primary_color": "#0A84FF",
"primary_hover_color": "#007AFF",
"icon_selected_color": "#D1D1D6",
"icon_unselected_color": "#4A4A4A",
"icon_selected_border_color": "#D1D1D6",
"icon_hover_color": "#D1D1D6"
}
}

16
data/themes/light.json Normal file
View File

@ -0,0 +1,16 @@
{
"theme_name": "light",
"colors": {
"background_color": "#FFFFFF",
"background_secondary_color": "#F5F5F5",
"background_tertiary_color": "#E0E0E0",
"border_color": "#1f1f20",
"text_color": "#000000",
"primary_color": "#0A84FF",
"primary_hover_color": "#007AFF",
"icon_selected_color": "#000000",
"icon_unselected_color": "#5D5A5A",
"icon_selected_border_color": "#000000",
"icon_hover_color": "#000000"
}
}

47
main.py Normal file
View File

@ -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())

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
PyQt6
pyinstaller
python-dotenv
requests

56
tools/build.bat Normal file
View File

@ -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

59
tools/build.command Normal file
View File

@ -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"

53
tools/open.bat Normal file
View File

@ -0,0 +1,53 @@
@echo off
setlocal enabledelayedexpansion
REM Set file paths
set ROOT_DIR=%~dp0..
set CONFIG_FILE=%ROOT_DIR%\config.json
set REQUIREMENTS=%ROOT_DIR%\requirements.txt
set ENV_FILE=%ROOT_DIR%\.env
REM Check if .env file exists
if not exist "%ENV_FILE%" (
echo [ERROR] .env file not found. Please copy .env.example to .env and configure it.
exit /b 1
)
REM Extract config.json fields using PowerShell
for /f "delims=" %%i in ('powershell -NoProfile -Command ^
"Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty architecture"') do set ARCHITECTURE=%%i
REM Extract python path from .env file
for /f "usebackq tokens=2 delims==" %%i in (`findstr "PYTHON_PATH" "%ENV_FILE%"`) do set PYTHON_EXEC=%%i
REM Construct python executable path
set ENV_NAME=WINenv_%ARCHITECTURE%
set ENV_PATH=%ROOT_DIR%\%ENV_NAME%
if not exist "%PYTHON_EXEC%" (
echo [ERROR] Python introuvable à: %PYTHON_EXEC%
echo Veuillez vérifier votre .env ou le dossier d'installation.
exit /b 1
)
REM Show config info
echo [INFO] Configuration :
echo Python: %PYTHON_EXEC%
echo Env: %ENV_NAME%
REM Check if virtual environment exists
if not exist "%ENV_PATH%\Scripts" (
echo [INFO] Environnement virtuel introuvable, création...
"%PYTHON_EXEC%" -m venv "%ENV_PATH%"
echo [INFO] Installation des dépendances...
"%ENV_PATH%\Scripts\python" -m pip install --upgrade pip
"%ENV_PATH%\Scripts\pip" install -r "%REQUIREMENTS%"
) else (
echo [INFO] Environnement virtuel trouvé.
)
REM Activate and launch VS Code
echo [INFO] Lancement de VS Code...
call "%ENV_PATH%/Scripts/activate.bat"
start code "%ROOT_DIR%"
exit /b 0

59
tools/open.command Normal file
View File

@ -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