2025-09-09 16:38:54 +02:00

570 lines
25 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 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 TabSide(Enum):
LEFT = 0 # Barre à gauche (pour VERTICAL)
RIGHT = 1 # Barre à droite (pour VERTICAL)
TOP = 0 # Barre en haut (pour HORIZONTAL)
BOTTOM = 1 # Barre en bas (pour HORIZONTAL)
class TabsWidget(QWidget):
def __init__(self, parent=None, direction=MenuDirection.VERTICAL, menu_width=80, onTabChange=None, spacing=10, button_size_ratio=0.8, border_side=BorderSide.LEFT, tab_side=None):
super().__init__(parent)
self.main_manager = MainManager.get_instance()
self.theme_manager = self.main_manager.get_theme_manager()
self.observer_manager = self.main_manager.get_observer_manager()
self.observer_manager.subscribe(NotificationType.THEME, self.set_theme)
self.direction = direction
self.menu_width = menu_width
self.button_size_ratio = button_size_ratio # Default ratio for button size relative to menu width
self.onTabChange = onTabChange
# Gérer border_side comme une liste ou un seul élément
if isinstance(border_side, list):
self.border_sides = border_side
elif border_side is not None:
self.border_sides = [border_side]
else:
self.border_sides = []
# Déterminer le côté de la barre d'onglets
if tab_side is None:
# Valeurs par défaut basées sur la direction
self.tab_side = TabSide.LEFT if direction == MenuDirection.VERTICAL else TabSide.TOP
else:
self.tab_side = tab_side
self.buttons = []
self.widgets = []
self.button_positions = []
self.button_size_ratios = [] # Individual ratios for each button
self._icon_cache = {}
# 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 and tab_side"""
if self.direction == MenuDirection.HORIZONTAL:
self.main_layout = QVBoxLayout(self)
self.button_layout = QHBoxLayout()
else: # VERTICAL
self.main_layout = QHBoxLayout(self)
self.button_layout = QVBoxLayout()
# Remove all spacing and margins
self.main_layout.setSpacing(0)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.button_layout.setSpacing(self.spacing)
self.button_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_widget = QStackedWidget()
# Create button container widget
self.button_container = QWidget()
self.button_container.setObjectName("tab_bar")
self.button_container.setLayout(self.button_layout)
# Remove all margins from button container
self.button_container.setContentsMargins(0, 0, 0, 0)
# Set minimum size for button container to prevent shrinking
if self.direction == MenuDirection.VERTICAL:
self.button_container.setMaximumWidth(self.menu_width)
self.button_container.setMinimumWidth(self.menu_width)
self.button_container.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
else:
self.button_container.setMinimumHeight(self.menu_width)
self.button_container.setMaximumHeight(self.menu_width)
self.button_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
# Initialize spacers for alignment zones
self._setup_alignment_zones()
# Add widgets to main layout based on direction and tab_side
if self.tab_side == TabSide.LEFT:
self.main_layout.addWidget(self.button_container)
self.main_layout.addWidget(self.stacked_widget)
else: # TabSide.RIGHT
self.main_layout.addWidget(self.stacked_widget)
self.main_layout.addWidget(self.button_container)
self.setLayout(self.main_layout)
def _setup_alignment_zones(self):
"""Setup spacers to create alignment zones"""
# Create spacers
if self.direction == MenuDirection.HORIZONTAL:
self.left_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.center_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
else:
self.left_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.center_spacer = QSpacerItem(0, 0, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
# Add spacers to layout (no right spacer so END buttons stick to edge)
self.button_layout.addSpacerItem(self.left_spacer) # Before center
self.button_layout.addSpacerItem(self.center_spacer) # After center
# Removed right_spacer so END buttons are at the edge
# Add a widget on the menu, just the widget, with position
def add_random_widget(self, widget, position=ButtonPosition.END):
"""Add a widget directly to the menu at the specified position"""
if isinstance(widget, QLayout):
# If it's a layout, wrap it in a container widget
container_widget = QWidget()
container_widget.setLayout(widget)
container_widget.setContentsMargins(0, 0, 0, 0)
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_sides setting
border_style = self._get_border_style()
button.setStyleSheet(border_style)
# Install event filter for hover detection
button.installEventFilter(self)
# Calculate initial size (will be updated in resizeEvent)
self._update_button_size(button)
# Store reference for resize updates
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_sides setting"""
if not self.border_sides or BorderSide.NONE in self.border_sides:
return f"""
QPushButton {{
border-radius: 0px;
background-color: transparent;
border: none;
}}
QPushButton[selected="true"] {{
border-radius: 0px;
background-color: transparent;
border: none;
}}
"""
# Construire le style CSS pour chaque côté de bordure
border_declarations = []
selected_border_declarations = []
for border_side in self.border_sides:
if border_side != BorderSide.NONE and border_side is not None:
border_declarations.append(f"border-{border_side.value}: 3px solid transparent")
selected_border_declarations.append(f"border-{border_side.value}: 3px solid {self.selected_border_icon_color}")
border_style = "; ".join(border_declarations)
selected_border_style = "; ".join(selected_border_declarations)
return f"""
QPushButton {{
border-radius: 0px;
background-color: transparent;
{border_style};
}}
QPushButton[selected="true"] {{
border-radius: 0px;
background-color: transparent;
{selected_border_style};
}}
"""
def _update_button_size(self, button):
"""Update button size based on menu width and button's individual ratio"""
# Find the button's index to get its specific ratio
button_index = -1
for i, btn in enumerate(self.buttons):
if btn == button:
button_index = i
break
if button_index == -1:
return # Button not found
# Get the specific ratio for this button
ratio = self.button_size_ratios[button_index] if button_index < len(self.button_size_ratios) else self.button_size_ratio
button_size = int(self.menu_width * ratio)
button.setFixedSize(QSize(button_size, button_size))
# Set proportional icon size
icon_size = int(button_size * 0.6)
button.setIconSize(QSize(icon_size, icon_size))
def _update_all_button_sizes(self):
"""Update all button sizes when container is resized"""
if hasattr(self, '_square_buttons'):
for button in self._square_buttons:
if button.parent(): # Check if button still exists
self._update_button_size(button)
def showEvent(self, event):
"""Handle show event to set initial button sizes"""
super().showEvent(event)
# Update button sizes when widget is first shown
self._update_all_button_sizes()
def _insert_button_with_alignment(self, button, position, after_button_index=None):
"""Insert button at the specified position with proper visual alignment"""
if position == ButtonPosition.START:
# Insert at the beginning (before left spacer)
self.button_layout.insertWidget(0, button)
self.start_buttons.append(button)
elif position == ButtonPosition.CENTER:
# Insert in center zone (after left spacer, before center spacer)
center_start_index = self._get_spacer_index(self.left_spacer) + 1
insert_index = center_start_index + len(self.center_buttons)
self.button_layout.insertWidget(insert_index, button)
self.center_buttons.append(button)
elif position == ButtonPosition.END:
# Insert in end zone (after center spacer, at the end)
end_start_index = self._get_spacer_index(self.center_spacer) + 1
insert_index = end_start_index + len(self.end_buttons)
self.button_layout.insertWidget(insert_index, button)
self.end_buttons.append(button)
elif position == ButtonPosition.AFTER and after_button_index is not None:
if 0 <= after_button_index < len(self.buttons) - 1:
target_button = self.buttons[after_button_index]
target_position = self.button_positions[after_button_index]
# Find the target button's layout index
target_layout_index = -1
for i in range(self.button_layout.count()):
item = self.button_layout.itemAt(i)
if item.widget() == target_button:
target_layout_index = i
break
if target_layout_index != -1:
self.button_layout.insertWidget(target_layout_index + 1, button)
# Add to the same alignment zone as target
if target_position == ButtonPosition.START:
self.start_buttons.append(button)
elif target_position == ButtonPosition.CENTER:
self.center_buttons.append(button)
elif target_position == ButtonPosition.END:
self.end_buttons.append(button)
else:
# Fallback to END position
self._insert_button_with_alignment(button, ButtonPosition.END)
def _get_spacer_index(self, spacer_item):
"""Get the index of a spacer item in the layout"""
for i in range(self.button_layout.count()):
if self.button_layout.itemAt(i).spacerItem() == spacer_item:
return i
return -1
def set_button_size_ratio(self, ratio, button_index=None):
"""Set the button size ratio globally or for a specific button"""
if button_index is not None:
# Set ratio for specific button
if 0 <= button_index < len(self.button_size_ratios):
self.button_size_ratios[button_index] = ratio
if button_index < len(self.buttons):
self._update_button_size(self.buttons[button_index])
else:
# Set global ratio
self.button_size_ratio = ratio
# Update all buttons that don't have individual ratios
for i in range(len(self.buttons)):
if i >= len(self.button_size_ratios):
self.button_size_ratios.append(ratio)
else:
self.button_size_ratios[i] = ratio
self._update_all_button_sizes()
def switch_to_tab(self, index):
"""Switch to the specified tab (only for widgets, not simple buttons)"""
if 0 <= index < len(self.buttons) and self.widgets[index] is not None:
old_index = self.stacked_widget.currentIndex()
# Update button states and apply theme colors
for i, button in enumerate(self.buttons):
# Only handle tab-like behavior for buttons with widgets
if self.widgets[i] is not None:
is_selected = (i == index)
button.setChecked(is_selected)
# Update icon color based on selection
if hasattr(self, '_original_icon_paths') and i < len(self._original_icon_paths):
icon_path = self._original_icon_paths[i]
if icon_path:
color = (self.selected_icon_color if is_selected
else self.unselected_icon_color)
colored_icon = self.apply_color_to_svg_icon(icon_path, color)
button.setIcon(colored_icon)
# Update button property for styling
button.setProperty("selected", is_selected)
button.style().unpolish(button)
button.style().polish(button)
# Switch stacked widget
self.stacked_widget.setCurrentIndex(index)
# Call the callback if provided and index actually changed
if self.onTabChange and old_index != index:
try:
self.onTabChange(index)
except Exception 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))