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 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 self._icon_cache = {} # 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: """ 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))