From 9a61d20a57a701d52949a6111dd91b990d1c8fe7 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Fri, 11 Jul 2025 14:33:17 +0200 Subject: [PATCH] first commit --- .gitignore | 89 ++++++ BUILD.spec | 67 ++++ LICENSE | 9 + README.md | 0 app/core/language_manager.py | 39 +++ app/core/main_manager.py | 32 ++ app/core/observer_manager.py | 39 +++ app/core/settings_manager.py | 76 +++++ app/core/theme_manager.py | 147 +++++++++ app/ui/main_window.py | 43 +++ app/ui/widgets/tabs_widget.py | 477 +++++++++++++++++++++++++++++ app/ui/windows/settings_window.py | 89 ++++++ app/utils/paths.py | 59 ++++ config.json | 10 + data/assets/icon.ico | Bin 0 -> 5516 bytes data/assets/icon.png | Bin 0 -> 16328 bytes data/assets/settings.svg | 1 + data/lang/en.json | 10 + data/lang/fr.json | 10 + data/others/defaults_settings.json | 7 + data/themes/dark.json | 13 + data/themes/light.json | 13 + main.py | 14 + requirements.txt | 2 + tools/build.bat | 50 +++ tools/open.bat | 49 +++ 26 files changed, 1345 insertions(+) create mode 100644 .gitignore create mode 100644 BUILD.spec create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/core/language_manager.py create mode 100644 app/core/main_manager.py create mode 100644 app/core/observer_manager.py create mode 100644 app/core/settings_manager.py create mode 100644 app/core/theme_manager.py create mode 100644 app/ui/main_window.py create mode 100644 app/ui/widgets/tabs_widget.py create mode 100644 app/ui/windows/settings_window.py create mode 100644 app/utils/paths.py create mode 100644 config.json create mode 100644 data/assets/icon.ico create mode 100644 data/assets/icon.png create mode 100644 data/assets/settings.svg create mode 100644 data/lang/en.json create mode 100644 data/lang/fr.json create mode 100644 data/others/defaults_settings.json create mode 100644 data/themes/dark.json create mode 100644 data/themes/light.json create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 tools/build.bat create mode 100644 tools/open.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..347c5af --- /dev/null +++ b/.gitignore @@ -0,0 +1,89 @@ +# Fichiers système +.DS_Store +Thumbs.db +desktop.ini + +# Dossiers de build et distributions +/build/ +/dist/ +/out/ +/release/ +*.exe +*.msi +*.dmg +*.pkg +*.app + +# Fichiers d'environnement et de configuration +.env +.env.local +.env.development +.env.test +.env.production +LINenv*/ +WINenv*/ +MACenv*/ + +# Fichiers de dépendances +/node_modules/ +/.pnp/ +.pnp.js +/vendor/ +/packages/ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Logs et bases de données +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.sqlite +*.db + +# Fichiers d'IDE et d'éditeurs +.idea/ +.vscode/ +*.swp +*.swo +*.sublime-workspace +*.sublime-project +.vs/ +*.user +*.userosscache +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Fichiers temporaires +/tmp/ +/temp/ +*.tmp +*.bak +*~ + +# Fichiers spécifiques à l'application +# Ne pas ignorer le fichier config.json +!config.json + +# Dossier de cache +.cache/ +.parcel-cache/ + +# Fichiers de couverture de test +coverage/ +.nyc_output/ +.coverage +htmlcov/ + +# Autres +.vercel +.next +.nuxt +.serverless/ + diff --git a/BUILD.spec b/BUILD.spec new file mode 100644 index 0000000..079d3d4 --- /dev/null +++ b/BUILD.spec @@ -0,0 +1,67 @@ +# -*- mode: python ; coding: utf-8 -*- +import json +from pathlib import Path + +# 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 = config.get("app_os", "Windows") +name = config.get("app_name", "Application") + +# Construct dynamic name +name = f"{name}-{os}-{arch}-v{version}" + +# Optional icon path (can still be overridden via environment if needed) +from os import getenv +icon = getenv("ICON_PATH", "") + +# Data files to bundle +datas = [ + ("data/assets/*", "data/assets/"), + ("data/", "data/") +] +binaries = [] + +a = Analysis( + ["main.py"], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) + +pyz = PYZ(a.pure) + +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, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c205385 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT 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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app/core/language_manager.py b/app/core/language_manager.py new file mode 100644 index 0000000..0f8aeca --- /dev/null +++ b/app/core/language_manager.py @@ -0,0 +1,39 @@ +from json import JSONDecodeError, load +from os import listdir, path +from typing import Dict +import app.utils.paths as paths + +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) -> None: + self.translations: Dict[str, Dict[str, str]] = {} + self.settings_manager = settings_manager + + self.load_all_translations() + + def load_all_translations(self) -> None: + """ + Charge tous les fichiers JSON dans data/lang/ comme dictionnaires. + """ + lang_dir = paths.get_lang_path() + + for filename in listdir(lang_dir): + if filename.endswith(".json"): + lang_code = filename[:-5] # strip .json + file_path = path.join(lang_dir, filename) + try: + with open(file_path, "r", encoding="utf-8") as f: + self.translations[lang_code] = load(f) + except JSONDecodeError as e: + pass + + def get_text(self, key: str) -> str: + """ + Retourne le texte traduit correspondant à la clé dans la langue courante. + Si la clé n'existe pas, retourne la clé elle-même. + """ + return self.translations.get(self.settings_manager.get_language(), {}).get(key, key) diff --git a/app/core/main_manager.py b/app/core/main_manager.py new file mode 100644 index 0000000..9f1e987 --- /dev/null +++ b/app/core/main_manager.py @@ -0,0 +1,32 @@ +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 + +class MainManager: + _instance = None + def __init__(self): + if MainManager._instance is not None: + raise Exception("This class is a singleton!") + else: + MainManager._instance = self + self.observer_manager = ObserverManager() + self.theme_manager = ThemeManager() + self.settings_manager = SettingsManager(self.observer_manager,self.theme_manager) + self.language_manager = LanguageManager(self.settings_manager) + + def get_observer_manager(self): + return self.observer_manager + def get_theme_manager(self): + return self.theme_manager + def get_settings_manager(self): + return self.settings_manager + def get_language_manager(self): + return self.language_manager + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + diff --git a/app/core/observer_manager.py b/app/core/observer_manager.py new file mode 100644 index 0000000..e1f8364 --- /dev/null +++ b/app/core/observer_manager.py @@ -0,0 +1,39 @@ +from typing import Callable, Dict, List, Any + +class NotificationType: + THEME = 0 + LANGUAGE = 1 + +class ObserverManager: + """ + Gestionnaire d'observateurs pour gérer les événements et notifier les abonnés. + Chaque événement est identifié par une clé (string), et les abonnés sont des callbacks. + """ + def __init__(self): + self.observers: Dict[str, List[Callable[[Any], None]]] = {} + + def subscribe(self, event: str, callback: Callable[[Any], None]) -> None: + """ + Ajoute un callback à la liste des abonnés pour l'événement donné. + """ + if event not in self.observers: + self.observers[event] = [] + if callback not in self.observers[event]: + self.observers[event].append(callback) + + def unsubscribe(self, event: str, callback: Callable[[Any], None]) -> None: + """ + Retire un callback de la liste des abonnés pour l'événement donné. + """ + if event in self.observers and callback in self.observers[event]: + self.observers[event].remove(callback) + if not self.observers[event]: + del self.observers[event] + + def notify(self, event: NotificationType) -> None: + """ + Notifie tous les abonnés de l'événement donné, en leur passant la donnée. + """ + if event in self.observers: + for callback in self.observers[event]: + callback() diff --git a/app/core/settings_manager.py b/app/core/settings_manager.py new file mode 100644 index 0000000..df58b4b --- /dev/null +++ b/app/core/settings_manager.py @@ -0,0 +1,76 @@ +from PyQt6.QtCore import QSettings +from app.core.observer_manager import NotificationType +from os import path +import app.utils.paths as paths +import json + +class SettingsManager: + """ + Gestion des paramètres utilisateurs avec sauvegarde persistante via QSettings. + Notifie les changements via ObserverManager. + """ + def __init__(self, observer_manager, theme_manager): + self.observer_manager = observer_manager + self.theme_manager = theme_manager + + # Load default settings from JSON file + defaults_path = path.join(paths.get_data_dir(), "others", "defaults_settings.json") + with open(defaults_path, 'r', encoding='utf-8') as f: + self.default_settings = json.load(f) + + config_path = path.join(paths.get_current_dir(), "config.json") + with open(config_path, 'r', encoding='utf-8') as f: + self.config = json.load(f) + + self.settings = QSettings(path.join(paths.get_user_data_dir(self.get_config("app_name")), self.get_config("app_name") + ".ini"), QSettings.Format.IniFormat) + + self.theme_manager.set_theme(self.get_theme()) + + # Config + def get_config(self, key: str): + return self.config.get(key, None) + + # Theme + def get_theme(self) -> str: + return self.settings.value("theme", self.default_settings.get("theme")) + + def set_theme(self, mode: str) -> None: + if mode != self.get_theme(): + self.settings.setValue("theme", mode) + self.theme_manager.set_theme(mode) + self.observer_manager.notify(NotificationType.THEME) + + # Language + def get_language(self) -> str: + return self.settings.value("lang", self.default_settings.get("lang")) + + def set_language(self, lang_code: str) -> None: + if lang_code != self.get_language(): + self.settings.setValue("lang", lang_code) + self.observer_manager.notify(NotificationType.LANGUAGE) + + # Window size and fullscreen + def get_window_size(self) -> dict: + return self.settings.value("window_size", self.default_settings.get("window_size")) + + def set_window_size(self, width: int, height: int) -> None: + 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) + + def get_fullscreen(self) -> bool: + return self.settings.value("fullscreen", self.default_settings.get("fullscreen")) + + def set_fullscreen(self, fullscreen: bool, width: int, height: int): + self.settings.setValue("fullscreen", fullscreen) + self.settings.setValue("fullscreen_size", {"width": width, "height": height}) + + def get_fullscreen_size(self) -> dict: + return self.settings.value("fullscreen_size", self.default_settings.get("fullscreen_size")) + + def set_fullscreen_size(self, width: int, height: int) -> None: + current_size = self.get_fullscreen_size() + if current_size["width"] != width or current_size["height"] != height: + new_size = {"width": width, "height": height} + self.settings.setValue("fullscreen_size", new_size) \ No newline at end of file diff --git a/app/core/theme_manager.py b/app/core/theme_manager.py new file mode 100644 index 0000000..94c89b7 --- /dev/null +++ b/app/core/theme_manager.py @@ -0,0 +1,147 @@ +import app.utils.paths as paths +import os, json + +class Theme: + def __init__(self, name: str, colors: dict): + self.name = name + self.colors = colors + + def get_color(self, element: str) -> str: + return self.colors.get(element, "#FFFFFF") + +class ThemeManager: + + def __init__(self): + theme_path = os.path.join(paths.get_data_dir(), "themes") + self.themes = [] + 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 = json.load(f) + theme = Theme(theme_data["theme_name"], theme_data["colors"]) + self.themes.append(theme) + self.current_theme = self.themes[0] + + def set_theme(self, theme: str) -> None: + if theme != self.current_theme.name: + self.current_theme = next((t for t in self.themes if t.name == theme), self.current_theme) + + def get_theme(self) -> str: + return self.current_theme + + def get_sheet(self) -> str: + return f""" + QWidget {{ + background-color: {self.current_theme.get_color("background")}; + color: {self.current_theme.get_color("font_color")}; + }} + QPushButton {{ + background-color: #0A84FF; + color: {self.current_theme.get_color("font_color")}; + border-radius: 8px; + font-size: 16px; + padding: 10px 20px; + border: none; + }} + QPushButton:hover {{ + background-color: #007AFF; + }} + QLineEdit {{ + border: 1px solid #3C3C3E; + border-radius: 8px; + padding: 10px; + font-size: 14px; + background-color: {self.current_theme.get_color("background2")}; + color: {self.current_theme.get_color("font_color")}; + }} + QLabel {{ + color: {self.current_theme.get_color("font_color")}; + }} + QProgressBar {{ + border: 1px solid #3C3C3E; + border-radius: 5px; + background-color: {self.current_theme.get_color("background2")}; + text-align: center; + color: {self.current_theme.get_color("font_color")}; + }} + QProgressBar::chunk {{ + background-color: #0A84FF; + border-radius: 3px; + }} + QFrame {{ + background-color: {self.current_theme.get_color("background2")}; + border: none; + }} + QFrame#indicator_bar {{ + background-color: #0A84FF; + }} + QScrollBar:vertical {{ + border: none; + background: #E0E0E0; + width: 8px; + margin: 0px; + }} + QScrollBar::handle:vertical {{ + background: #0A84FF; + border-radius: 4px; + min-height: 20px; + }} + QScrollBar::handle:vertical:hover {{ + background: #3B9CFF; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + border: none; + background: none; + height: 0px; + }} + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: none; + }} + #drag_area {{ + border: 2px dashed #3498db; border-radius: 10px; + }} + QSlider::groove:horizontal {{ + border: 1px solid #3a9bdc; + height: 10px; + background: transparent; + border-radius: 5px; + }} + QSlider::sub-page:horizontal {{ + background: #3a9bdc; + border-radius: 5px; + }} + QSlider::add-page:horizontal {{ + background: {self.current_theme.get_color("background3")}; + border-radius: 5px; + }} + QSlider::handle:horizontal {{ + background: white; + border: 2px solid #3a9bdc; + width: 14px; + margin: -4px 0; + border-radius: 7px; + }} + QComboBox {{ + padding: 5px; + border-radius: 8px; + font-size: 14px; + min-height: 30px; + }} + QComboBox QAbstractItemView {{ + border-radius: 8px; + padding: 0px; + }} + QComboBox QAbstractItemView::item {{ + padding: 12px 15px; + border: none; + margin: 0px; + min-height: 20px; + }} + QComboBox QAbstractItemView::item:hover {{ + background-color: {self.current_theme.get_color("background3")}; + color: {self.current_theme.get_color("font_color")}; + }} + QComboBox QAbstractItemView::item:selected {{ + color: {self.current_theme.get_color("font_color")}; + }} + """ \ No newline at end of file diff --git a/app/ui/main_window.py b/app/ui/main_window.py new file mode 100644 index 0000000..3e0f9c6 --- /dev/null +++ b/app/ui/main_window.py @@ -0,0 +1,43 @@ +from PyQt6.QtWidgets import QMainWindow +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QApplication +from app.core.main_manager import MainManager, NotificationType +from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition, BorderSide +from app.ui.windows.settings_window import SettingsWindow +import app.utils.paths as paths + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + + self.main_manager = MainManager() + 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) + + # Configurer la fenêtre avant de créer les widgets + app = QApplication.instance() + self.settings_manager.dpi = app.primaryScreen().devicePixelRatio() + size = app.primaryScreen().size() + self.settings_manager.minScreenSize = min(size.height(),size.width()) + configWidth = int(self.settings_manager.get_fullscreen_size()["width"] if self.settings_manager.get_fullscreen() else self.settings_manager.get_window_size()["width"] /self.settings_manager.dpi) + configHeight = int(self.settings_manager.get_fullscreen_size()["height"] if self.settings_manager.get_fullscreen() else self.settings_manager.get_window_size()["height"] /self.settings_manager.dpi) + self.setMinimumSize(600, 400) + self.resize(configWidth, configHeight) + self.setWindowTitle(self.settings_manager.get_config("app_name")) + self.setStyleSheet(self.theme_manager.get_sheet()) + self.setWindowIcon(QIcon(paths.get_asset_path("icon"))) + self.setup_ui() + + def setup_ui(self): + self.side_menu = TabsWidget(self,MenuDirection.HORIZONTAL,70,None,10,1,BorderSide.TOP) + + 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): + self.setStyleSheet(self.theme_manager.get_sheet()) \ No newline at end of file diff --git a/app/ui/widgets/tabs_widget.py b/app/ui/widgets/tabs_widget.py new file mode 100644 index 0000000..c2fb5b5 --- /dev/null +++ b/app/ui/widgets/tabs_widget.py @@ -0,0 +1,477 @@ +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 app.core.main_manager import MainManager, NotificationType +import xml.etree.ElementTree as ET + +class MenuDirection(Enum): + HORIZONTAL = 0 + VERTICAL = 1 + +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" + RIGHT = "right" + TOP = "top" + BOTTOM = "bottom" + NONE = None + +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): + 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 + self.border_side = border_side # Store border side preference + self.buttons = [] + self.widgets = [] + self.button_positions = [] + self.button_size_ratios = [] # Individual ratios for each button + + # 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("selected_icon") + self.unselected_icon_color = self.theme_manager.current_theme.get_color("unselected_icon") + self.selected_border_icon_color = self.theme_manager.current_theme.get_color("selected_border_icon") + self.hover_icon_color = self.theme_manager.current_theme.get_color("hover_icon") + + # 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""" + 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.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 + if self.direction == MenuDirection.HORIZONTAL: + self.main_layout.addWidget(self.button_container) + self.main_layout.addWidget(self.stacked_widget) + else: # VERTICAL + self.main_layout.addWidget(self.button_container) + self.main_layout.addWidget(self.stacked_widget) + + 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) + self._insert_widget_with_alignment(container_widget, position) + return container_widget + else: + # If it's already a widget, insert it directly + 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""" + # Store original icon path for theme updates + if not hasattr(self, '_original_icon_paths'): + self._original_icon_paths = [] + + # 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.setObjectName("menu_button") + 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_side 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 + if not hasattr(self, '_square_buttons'): + self._square_buttons = [] + 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_side setting""" + if self.border_side == BorderSide.NONE or self.border_side is None: + return f""" + QPushButton {{ + border-radius: 0px; + background-color: transparent; + border: none; + }} + QPushButton[selected="true"] {{ + border-radius: 0px; + background-color: transparent; + border: none; + }} + """ + + return f""" + QPushButton {{ + border-radius: 0px; + background-color: transparent; + border-{self.border_side.value}: 3px solid transparent; + }} + QPushButton[selected="true"] {{ + border-radius: 0px; + background-color: transparent; + border-{self.border_side.value}: 3px solid {self.selected_border_icon_color}; + }} + """ + + 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 as e: + print(f"Error in onTabChange callback: {e}") + + def set_theme(self): + self.selected_icon_color = self.theme_manager.current_theme.get_color("selected_icon") + self.unselected_icon_color = self.theme_manager.current_theme.get_color("unselected_icon") + self.selected_border_icon_color = self.theme_manager.current_theme.get_color("selected_border_icon") + self.hover_icon_color = self.theme_manager.current_theme.get_color("hover_icon") + # 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: + + # Define namespace mapping (based on your SVG) + ns = {'ns0': 'http://www.w3.org/2000/svg'} + + # Parse the SVG file + tree = ET.parse(icon_path) + root = tree.getroot() + + # Change the fill attribute on the root element + root.attrib['fill'] = color + root.attrib['stroke'] = color + # Optionally, change fill for all elements as well + for path in root.findall('.//ns0:path', ns): + path.attrib['fill'] = color + path.attrib['stroke'] = color + + # Save the modified SVG + tree.write(icon_path) + return QIcon(icon_path) + + def update_buttons_size(self, size): + """Update all buttons to a specific pixel size (overrides ratio-based sizing)""" + for button in self.buttons: + button.setFixedSize(size, size) + # Update icon size proportionally + icon_size = max(int(size * 0.6), 12) + button.setIconSize(QSize(icon_size, icon_size)) \ No newline at end of file diff --git a/app/ui/windows/settings_window.py b/app/ui/windows/settings_window.py new file mode 100644 index 0000000..97d20bb --- /dev/null +++ b/app/ui/windows/settings_window.py @@ -0,0 +1,89 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QLabel, QHBoxLayout, QSizePolicy +from PyQt6.QtCore import Qt +from app.core.main_manager import MainManager, NotificationType + +class SettingsWindow(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.main_manager = MainManager.get_instance() + self.language_manager = self.main_manager.get_language_manager() + self.settings_manager = self.main_manager.get_settings_manager() + + self.observer_manager = self.main_manager.get_observer_manager() + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + 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) + + # 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): + combo = 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): + combo = QComboBox() + + # Ajouter les options de thème + combo.addItem(self.language_manager.get_text("light_theme"), "light") + combo.addItem(self.language_manager.get_text("dark_theme"), "dark") + + # 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): + self.settings_manager.set_language(self.languageCombo.itemData(index)) + + def change_theme(self, index): + theme = self.themeCombo.itemData(index) + self.settings_manager.set_theme(theme) + + def update_language(self): + self.languageLabel.setText(self.language_manager.get_text("language")) + self.themeLabel.setText(self.language_manager.get_text("theme")) + + # Mettre à jour les textes dans la combo de thème + for i in range(self.themeCombo.count()): + self.themeCombo.setItemText(i, self.language_manager.get_text(self.themeCombo.itemData(i)+ "_theme")) \ No newline at end of file diff --git a/app/utils/paths.py b/app/utils/paths.py new file mode 100644 index 0000000..3dad0ef --- /dev/null +++ b/app/utils/paths.py @@ -0,0 +1,59 @@ +from os import path, getenv, mkdir +import sys +from platform import system +from pathlib import Path + +def resource_path(relative_path: str) -> Path: + """ + Get absolute path to resource, works for dev and for PyInstaller bundle. + + PyInstaller stores bundled files in _MEIPASS folder. + """ + try: + base_path = Path(sys._MEIPASS) # PyInstaller temp folder + except AttributeError: + base_path = Path(__file__).parent.parent.parent # Dev environment: source/ folder + + return path.join(base_path, relative_path) + +def get_data_dir() -> Path: + return resource_path("data") + +def get_lang_path() -> Path: + return path.join(get_data_dir(), "lang") + +def get_asset_path(asset: str) -> Path: + return path.join(get_data_dir(), "assets", f"{asset}.png") + +def get_asset_svg_path(asset: str) -> Path: + return path.join(get_data_dir(), "assets", f"{asset}.svg") + +def get_user_data_dir(app_name: str) -> Path: + home = Path.home() + os = system() + + if os == "Windows": + appdata = getenv('APPDATA') + if appdata: + user_data_path = path.join(Path(appdata), app_name) + else: + user_data_path = path.join(home, "AppData", "Roaming", app_name) + elif os == "Darwin": + user_data_path = path.join(home, "Library", "Application Support", app_name) + else: + user_data_path = path.join(home, ".local", "share", app_name) + + if not path.exists(user_data_path): + mkdir(user_data_path) + return user_data_path + +def get_current_dir() -> Path: + """ + Return the directory where the exe or main script is located. + """ + if getattr(sys, 'frozen', False): + # If the application is frozen (PyInstaller) + return str(Path(sys.executable).parent) + else: + # If running in a normal Python environment + return str(Path(__file__).parent.parent.parent) \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..2462d61 --- /dev/null +++ b/config.json @@ -0,0 +1,10 @@ +{ + "app_name": "Application", + "python_version": "3.11.7", + "app_os": "Windows", + "app_version": "1.0.0", + "architecture": "x64", + "icon_path": "data/assets/icon.ico", + "main_script": "main.py", + "python_path": "C:/Logiciels/Python/x64/3.11.7/python.exe" +} \ No newline at end of file diff --git a/data/assets/icon.ico b/data/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ab5551cfbeef158858534f411e82e12665b5a973 GIT binary patch literal 5516 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 DvEs$W literal 0 HcmV?d00001 diff --git a/data/assets/icon.png b/data/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..139ce310d9189bf21e3b91fa905f4bf1e9b496d7 GIT binary patch literal 16328 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 literal 0 HcmV?d00001 diff --git a/data/assets/settings.svg b/data/assets/settings.svg new file mode 100644 index 0000000..9dab5b7 --- /dev/null +++ b/data/assets/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/lang/en.json b/data/lang/en.json new file mode 100644 index 0000000..d0ec107 --- /dev/null +++ b/data/lang/en.json @@ -0,0 +1,10 @@ +{ + "lang_name": "English", + "yes": "Yes", + "no": "No", + "language": "Language :", + "settings": "Settings", + "theme": "Theme :", + "dark_theme": "Dark Theme", + "light_theme": "Light Theme" +} \ No newline at end of file diff --git a/data/lang/fr.json b/data/lang/fr.json new file mode 100644 index 0000000..fd8150a --- /dev/null +++ b/data/lang/fr.json @@ -0,0 +1,10 @@ +{ + "lang_name": "Français", + "yes": "Oui", + "no": "Non", + "language": "Langue :", + "settings": "Paramètres", + "theme": "Thème :", + "dark_theme": "Thème Sombre", + "light_theme": "Thème Clair" +} \ No newline at end of file diff --git a/data/others/defaults_settings.json b/data/others/defaults_settings.json new file mode 100644 index 0000000..64a915d --- /dev/null +++ b/data/others/defaults_settings.json @@ -0,0 +1,7 @@ +{ + "theme": "dark", + "lang": "fr", + "window_size": {"width": 1000, "height": 600}, + "fullscreen": true, + "fullscreen_size": {"width": 1000, "height": 600} +} \ No newline at end of file diff --git a/data/themes/dark.json b/data/themes/dark.json new file mode 100644 index 0000000..62fed6c --- /dev/null +++ b/data/themes/dark.json @@ -0,0 +1,13 @@ +{ + "theme_name": "dark", + "colors": { + "background": "#212121", + "background2": "#2C2C2E", + "background3": "#4A4A4A", + "font_color": "#D1D1D6", + "selected_icon": "#D1D1D6", + "unselected_icon": "#4A4A4A", + "selected_border_icon": "#D1D1D6", + "hover_icon": "#D1D1D6" + } +} \ No newline at end of file diff --git a/data/themes/light.json b/data/themes/light.json new file mode 100644 index 0000000..165d998 --- /dev/null +++ b/data/themes/light.json @@ -0,0 +1,13 @@ +{ + "theme_name": "light", + "colors": { + "background": "#FFFFFF", + "background2": "#F5F5F5", + "background3": "#E0E0E0", + "font_color": "#000000", + "selected_icon": "#000000", + "unselected_icon": "#5D5A5A", + "selected_border_icon": "#000000", + "hover_icon": "#000000" + } +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..a539fcd --- /dev/null +++ b/main.py @@ -0,0 +1,14 @@ +import sys +from PyQt6.QtWidgets import QApplication +from app.ui.main_window import MainWindow + +def main(): + app = QApplication(sys.argv) + + window = MainWindow() + + window.show() + return app.exec() + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..69352a3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyQt6 +pyinstaller \ No newline at end of file diff --git a/tools/build.bat b/tools/build.bat new file mode 100644 index 0000000..49fb6b0 --- /dev/null +++ b/tools/build.bat @@ -0,0 +1,50 @@ +@echo off +setlocal enabledelayedexpansion + +REM === PATH SETUP === +set PARENT_DIR=%~dp0.. +set CONFIG_FILE=%PARENT_DIR%\config.json + +REM === Extract values from config.json === +for /f "delims=" %%i in ('powershell -NoProfile -Command ^ + "Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty python_version"') do set PYTHON_VERSION=%%i +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 +for /f "delims=" %%i in ('powershell -NoProfile -Command ^ + "Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty python_path"') 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%\dist" ^ + --clean ^ + "%PARENT_DIR%\BUILD.spec" + +REM === Clean build cache === +rmdir /s /q "%PARENT_DIR%\dist" + +endlocal diff --git a/tools/open.bat b/tools/open.bat new file mode 100644 index 0000000..dd52d62 --- /dev/null +++ b/tools/open.bat @@ -0,0 +1,49 @@ +@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 + +REM Extract config.json fields using PowerShell +for /f "delims=" %%i in ('powershell -NoProfile -Command ^ + "Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty python_version"') do set PYTHON_VERSION=%%i + +for /f "delims=" %%i in ('powershell -NoProfile -Command ^ + "Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty architecture"') do set ARCHITECTURE=%%i + +for /f "delims=" %%i in ('powershell -NoProfile -Command ^ + "Get-Content '%CONFIG_FILE%' | ConvertFrom-Json | Select-Object -ExpandProperty python_path"') 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 config.json 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