2025-07-11 14:33:17 +02:00

477 lines
21 KiB
Python

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 <svg> element
root.attrib['fill'] = color
root.attrib['stroke'] = color
# Optionally, change fill for all <path> 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))