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