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