760 lines
35 KiB
Python
760 lines
35 KiB
Python
from PyQt6.QtWidgets import QLayout, QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QStackedWidget, QSizePolicy, QSpacerItem, QLabel
|
|
from PyQt6.QtGui import QIcon
|
|
from PyQt6.QtCore import QSize, Qt
|
|
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 # Barre en haut ou en bas
|
|
VERTICAL = 1 # Barre à gauche ou à droite
|
|
|
|
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" # Bord gauche
|
|
RIGHT = "right" # Bord droit
|
|
TOP = "top" # Bord supérieur
|
|
BOTTOM = "bottom" # Bord inférieur
|
|
NONE = None
|
|
|
|
class TextPosition(Enum):
|
|
LEFT = 0 # Texte à gauche de l'icône
|
|
RIGHT = 1 # Texte à droite de l'icône
|
|
TOP = 2 # Texte au-dessus de l'icône
|
|
BOTTOM = 3 # Texte en dessous de l'icône
|
|
|
|
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, border_side=BorderSide.LEFT, tab_side=None, text_position=TextPosition.BOTTOM):
|
|
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.onTabChange = onTabChange
|
|
self.text_position = text_position # Position du texte par rapport à l'icône
|
|
|
|
# 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:
|
|
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_text_positions = [] # Individual text positions for each button
|
|
self._icon_cache = {}
|
|
self._original_icon_paths = []
|
|
self._square_buttons = []
|
|
# 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("icon_selected_color")
|
|
self.unselected_icon_color = self.theme_manager.current_theme.get_color("icon_unselected_color")
|
|
self.selected_border_icon_color = self.theme_manager.current_theme.get_color("icon_selected_border_color")
|
|
self.hover_icon_color = self.theme_manager.current_theme.get_color("icon_hover_color")
|
|
|
|
# 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)
|
|
widget = container_widget
|
|
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, text_position=None):
|
|
"""Add a widget with its corresponding button at specified position"""
|
|
# Use provided text_position or default to widget's text_position
|
|
btn_text_position = text_position if text_position is not None else self.text_position
|
|
|
|
# Create button container with custom layout
|
|
button_container = self._create_button_with_layout(button_text, icon_path, btn_text_position)
|
|
|
|
self._original_icon_paths.append(icon_path)
|
|
|
|
button_container.setCheckable(True)
|
|
|
|
self.button_text_positions.append(btn_text_position)
|
|
|
|
# Make button square with specified ratio
|
|
self._style_square_button(button_container)
|
|
|
|
# Configurer le widget pour qu'il soit responsive
|
|
widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
|
|
# Add to collections first
|
|
widget_index = len(self.widgets)
|
|
self.buttons.append(button_container)
|
|
self.widgets.append(widget)
|
|
self.button_positions.append(position)
|
|
|
|
# Connect button to switch function
|
|
button_container.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_container, position, after_button_index)
|
|
|
|
# Select first tab by default
|
|
if len(self.buttons) == 1:
|
|
self.switch_to_tab(0)
|
|
|
|
return widget_index
|
|
|
|
def _create_button_with_layout(self, text: str, icon_path: str, text_position: TextPosition) -> QPushButton:
|
|
"""Create a button with custom layout for icon and text positioning"""
|
|
button = QPushButton()
|
|
|
|
has_icon = icon_path is not None and icon_path != ""
|
|
has_text = text is not None and text.strip() != ""
|
|
|
|
# Create icon label only if there's an icon
|
|
icon_label = None
|
|
if has_icon:
|
|
icon_label = QLabel()
|
|
icon_label.setObjectName("icon_label")
|
|
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter)
|
|
colored_icon = self.apply_color_to_svg_icon(icon_path, self.unselected_icon_color)
|
|
pixmap = colored_icon.pixmap(QSize(32, 32)) # Taille par défaut, sera ajustée
|
|
icon_label.setPixmap(pixmap)
|
|
|
|
# Create text label only if there's text
|
|
text_label = None
|
|
if has_text:
|
|
text_label = QLabel(text)
|
|
text_label.setObjectName("text_label")
|
|
text_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter)
|
|
text_label.setWordWrap(True)
|
|
|
|
# Create layout based on what we have and text position
|
|
if has_icon and has_text:
|
|
# Both icon and text
|
|
if text_position == TextPosition.LEFT:
|
|
layout = QHBoxLayout()
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
elif text_position == TextPosition.RIGHT:
|
|
layout = QHBoxLayout()
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
elif text_position == TextPosition.TOP:
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
else: # BOTTOM (default)
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
elif has_icon:
|
|
# Only icon
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
elif has_text:
|
|
# Only text
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
else:
|
|
# Neither icon nor text - empty button
|
|
layout = QVBoxLayout()
|
|
|
|
layout.setContentsMargins(2, 2, 2, 2)
|
|
layout.setSpacing(2)
|
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
button.setLayout(layout)
|
|
|
|
# Store references to labels for later updates (can be None)
|
|
button.icon_label = icon_label
|
|
button.text_label = text_label
|
|
|
|
return button
|
|
|
|
def _apply_text_position(self, button: QPushButton, text_position: TextPosition):
|
|
"""Apply text position to button by recreating its layout"""
|
|
# Get existing labels
|
|
if not hasattr(button, 'icon_label') or not hasattr(button, 'text_label'):
|
|
return
|
|
|
|
icon_label = button.icon_label
|
|
text_label = button.text_label
|
|
|
|
has_icon = icon_label is not None
|
|
has_text = text_label is not None
|
|
|
|
# Remove old layout
|
|
old_layout = button.layout()
|
|
if old_layout:
|
|
# Remove widgets from layout
|
|
while old_layout.count():
|
|
item = old_layout.takeAt(0)
|
|
if item.widget():
|
|
item.widget().setParent(None)
|
|
QWidget().setLayout(old_layout) # Delete old layout
|
|
|
|
# Create new layout based on what we have and text position
|
|
if has_icon and has_text:
|
|
if text_position == TextPosition.LEFT:
|
|
layout = QHBoxLayout()
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
elif text_position == TextPosition.RIGHT:
|
|
layout = QHBoxLayout()
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
elif text_position == TextPosition.TOP:
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
else: # BOTTOM (default)
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
elif has_icon:
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
elif has_text:
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
else:
|
|
layout = QVBoxLayout()
|
|
|
|
layout.setContentsMargins(2, 2, 2, 2)
|
|
layout.setSpacing(2)
|
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
button.setLayout(layout)
|
|
|
|
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
|
|
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
|
|
button_index = self.buttons.index(obj)
|
|
if hasattr(obj, 'icon_label') and button_index < len(self._original_icon_paths):
|
|
icon_path = self._original_icon_paths[button_index]
|
|
if icon_path:
|
|
colored_icon = self.apply_color_to_svg_icon(icon_path, self.hover_icon_color)
|
|
current_size = obj.icon_label.size()
|
|
pixmap = colored_icon.pixmap(current_size)
|
|
obj.icon_label.setPixmap(pixmap)
|
|
elif event.type() == event.Type.Leave:
|
|
# Mouse left button
|
|
button_index = self.buttons.index(obj)
|
|
if hasattr(obj, 'icon_label') and button_index < len(self._original_icon_paths):
|
|
icon_path = self._original_icon_paths[button_index]
|
|
if icon_path:
|
|
color = self.unselected_icon_color if not obj.isChecked() else self.selected_icon_color
|
|
colored_icon = self.apply_color_to_svg_icon(icon_path, color)
|
|
current_size = obj.icon_label.size()
|
|
pixmap = colored_icon.pixmap(current_size)
|
|
obj.icon_label.setPixmap(pixmap)
|
|
|
|
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 - will be recalculated globally"""
|
|
# This method now triggers a global recalculation
|
|
self._recalculate_all_buttons_size()
|
|
|
|
def _recalculate_all_buttons_size(self):
|
|
"""Recalculate size for all buttons to ensure uniform sizing"""
|
|
if not self.buttons:
|
|
return
|
|
|
|
max_secondary_size = 0
|
|
|
|
# First pass: calculate the maximum secondary dimension needed
|
|
for i, button in enumerate(self.buttons):
|
|
has_icon = button.icon_label is not None
|
|
has_text = button.text_label is not None
|
|
|
|
if has_icon and has_text:
|
|
text_pos = self.button_text_positions[i] if i < len(self.button_text_positions) else self.text_position
|
|
if self.direction == MenuDirection.VERTICAL:
|
|
# Vertical menu: calculate needed height
|
|
if text_pos in [TextPosition.TOP, TextPosition.BOTTOM]:
|
|
# Vertical layout: need square
|
|
max_secondary_size = max(max_secondary_size, self.menu_width)
|
|
else:
|
|
# Horizontal layout: need less height but ensure text fits
|
|
if has_text and button.text_label:
|
|
text_height = button.text_label.sizeHint().height() + 20
|
|
icon_height = int(self.menu_width * 0.4) + 10
|
|
needed_height = max(text_height, icon_height, int(self.menu_width * 0.6))
|
|
max_secondary_size = max(max_secondary_size, needed_height)
|
|
else:
|
|
# Horizontal menu: calculate needed width
|
|
if text_pos in [TextPosition.LEFT, TextPosition.RIGHT]:
|
|
# Horizontal layout: need square
|
|
max_secondary_size = max(max_secondary_size, self.menu_width)
|
|
else:
|
|
# Vertical layout: need less width but ensure text fits
|
|
if has_text and button.text_label:
|
|
text_width = button.text_label.sizeHint().width() + 20
|
|
icon_width = int(self.menu_width * 0.4) + 10
|
|
needed_width = max(text_width, icon_width, int(self.menu_width * 0.6))
|
|
max_secondary_size = max(max_secondary_size, needed_width)
|
|
elif has_icon or has_text:
|
|
# Only icon or only text
|
|
if has_text and button.text_label:
|
|
if self.direction == MenuDirection.VERTICAL:
|
|
text_height = button.text_label.sizeHint().height() + 20
|
|
max_secondary_size = max(max_secondary_size, text_height, int(self.menu_width * 0.6))
|
|
else:
|
|
text_width = button.text_label.sizeHint().width() + 20
|
|
max_secondary_size = max(max_secondary_size, text_width, int(self.menu_width * 0.6))
|
|
else:
|
|
max_secondary_size = max(max_secondary_size, int(self.menu_width * 0.6))
|
|
else:
|
|
# Empty button
|
|
max_secondary_size = max(max_secondary_size, int(self.menu_width * 0.4))
|
|
|
|
# Ensure minimum size
|
|
max_secondary_size = max(max_secondary_size, int(self.menu_width * 0.6))
|
|
|
|
# Second pass: apply uniform size to all buttons
|
|
for i, button in enumerate(self.buttons):
|
|
has_icon = button.icon_label is not None
|
|
|
|
if self.direction == MenuDirection.VERTICAL:
|
|
# Vertical: width = menu_width, height = max calculated
|
|
button_width = self.menu_width
|
|
button_height = max_secondary_size
|
|
else:
|
|
# Horizontal: height = menu_width, width = max calculated
|
|
button_height = self.menu_width
|
|
button_width = max_secondary_size
|
|
|
|
button.setFixedSize(QSize(button_width, button_height))
|
|
|
|
# Update icon size if it exists
|
|
if has_icon and i < len(self._original_icon_paths) and self._original_icon_paths[i]:
|
|
icon_path = self._original_icon_paths[i]
|
|
# Icon size is 40% of the smaller dimension
|
|
icon_size = int(min(button_width, button_height) * 0.4)
|
|
is_selected = button.isChecked()
|
|
color = self.selected_icon_color if is_selected else self.unselected_icon_color
|
|
colored_icon = self.apply_color_to_svg_icon(icon_path, color)
|
|
pixmap = colored_icon.pixmap(QSize(icon_size, icon_size))
|
|
button.icon_label.setPixmap(pixmap)
|
|
button.icon_label.setFixedSize(QSize(icon_size, icon_size))
|
|
|
|
def _update_all_button_sizes(self):
|
|
"""Update all button sizes when container is resized"""
|
|
if hasattr(self, '_square_buttons') and self._square_buttons:
|
|
self._recalculate_all_buttons_size()
|
|
|
|
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):
|
|
"""Deprecated: Button sizes now adapt automatically to content"""
|
|
pass
|
|
|
|
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(button, 'icon_label') 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)
|
|
|
|
# Get current icon size
|
|
current_size = button.icon_label.size()
|
|
pixmap = colored_icon.pixmap(current_size)
|
|
button.icon_label.setPixmap(pixmap)
|
|
|
|
# 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:
|
|
pass
|
|
|
|
def set_theme(self):
|
|
self.selected_icon_color = self.theme_manager.current_theme.get_color("icon_selected_color")
|
|
self.unselected_icon_color = self.theme_manager.current_theme.get_color("icon_unselected_color")
|
|
self.selected_border_icon_color = self.theme_manager.current_theme.get_color("icon_selected_border_color")
|
|
self.hover_icon_color = self.theme_manager.current_theme.get_color("icon_hover_color")
|
|
# 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)
|
|
|
|
# Update icon color if icon_label exists
|
|
if hasattr(button, 'icon_label') 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)
|
|
current_size = button.icon_label.size()
|
|
pixmap = colored_icon.pixmap(current_size)
|
|
button.icon_label.setPixmap(pixmap)
|
|
|
|
# Apply button styling based on selection state
|
|
button.setProperty("selected", is_selected)
|
|
|
|
# Force style update
|
|
button.style().unpolish(button)
|
|
button.style().polish(button)
|
|
|
|
def update_button_text(self, index, new_text):
|
|
"""Update the text of a button at the specified index"""
|
|
if 0 <= index < len(self.buttons):
|
|
button = self.buttons[index]
|
|
if hasattr(button, 'text_label') and button.text_label is not None:
|
|
button.text_label.setText(new_text)
|
|
# Optionally, update button size after text change
|
|
self._update_button_size(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 size"""
|
|
self._update_all_button_sizes() |