From d8a7ab6ba0076cc45b056c5a1721fabf12b5f40b Mon Sep 17 00:00:00 2001 From: LemonNexus Date: Mon, 16 Feb 2026 00:11:43 +0000 Subject: [PATCH] feat: Expandable left sidebar with toggle button NavigationRail now supports expanded/collapsed states: - Click menu toggle button to expand/collapse - Expanded: Shows icon + label (200px width) - Collapsed: Shows icon only (80px width) - Smooth animated width transition - Orange accent on active item maintained NavigationDestination updated: - Shows QLabel with text when expanded - Icon-only when collapsed - Proper styling for both states --- core/perfect_ux.py | 238 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 200 insertions(+), 38 deletions(-) diff --git a/core/perfect_ux.py b/core/perfect_ux.py index 69dc697..bc6686d 100644 --- a/core/perfect_ux.py +++ b/core/perfect_ux.py @@ -329,21 +329,26 @@ class Card(Surface): class NavigationRail(QFrame): - """Material Design Navigation Rail with EU styling.""" + """Material Design Navigation Rail with EU styling - Expandable with labels.""" destination_changed = pyqtSignal(str) + # Widths for collapsed/expanded states + WIDTH_COLLAPSED = 80 + WIDTH_EXPANDED = 200 + def __init__(self, parent=None): super().__init__(parent) self.destinations = [] self.active_destination = None self.icon_manager = get_icon_manager() + self._expanded = False self._setup_style() self._setup_layout() def _setup_style(self): """Apply navigation rail styling.""" - self.setFixedWidth(80) + self.setFixedWidth(self.WIDTH_COLLAPSED) self.setStyleSheet(""" NavigationRail { background: rgba(20, 31, 35, 0.98); @@ -352,21 +357,101 @@ class NavigationRail(QFrame): """) def _setup_layout(self): - """Setup vertical layout.""" - self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(12, 24, 12, 24) - self.layout.setSpacing(8) - self.layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) + """Setup vertical layout with expand toggle.""" + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 12, 0, 12) + self.main_layout.setSpacing(0) + + # Top toggle button + self.toggle_btn = QPushButton() + self.toggle_btn.setFixedSize(56, 32) + self.toggle_btn.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) + self.toggle_btn.setToolTip("Expand sidebar") + self.toggle_btn.setIcon(self.icon_manager.get_icon("menu")) + self.toggle_btn.setIconSize(QSize(20, 20)) + self.toggle_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: none; + border-radius: 8px; + margin: 0 12px; + }} + QPushButton:hover {{ + background: rgba(255, 140, 66, 0.15); + }} + """) + self.toggle_btn.clicked.connect(self._toggle_expanded) + + toggle_container = QWidget() + toggle_layout = QHBoxLayout(toggle_container) + toggle_layout.setContentsMargins(0, 0, 0, 0) + toggle_layout.addWidget(self.toggle_btn) + toggle_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.main_layout.addWidget(toggle_container) + + # Spacer after toggle + self.main_layout.addSpacing(16) + + # Destinations container + self.destinations_widget = QWidget() + self.destinations_layout = QVBoxLayout(self.destinations_widget) + self.destinations_layout.setContentsMargins(12, 0, 12, 0) + self.destinations_layout.setSpacing(8) + self.destinations_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.main_layout.addWidget(self.destinations_widget) # Add spacer at bottom - self.layout.addStretch() + self.main_layout.addStretch() + + def _toggle_expanded(self): + """Toggle between collapsed and expanded states.""" + self._expanded = not self._expanded + + # Animate width change + self._animate_width() + + # Update toggle button icon + if self._expanded: + self.toggle_btn.setToolTip("Collapse sidebar") + self.toggle_btn.setIcon(self.icon_manager.get_icon("close")) # Or "collapse" icon + else: + self.toggle_btn.setToolTip("Expand sidebar") + self.toggle_btn.setIcon(self.icon_manager.get_icon("menu")) + + # Update all destinations to show/hide labels + for dest in self.destinations: + dest.set_expanded(self._expanded) + + def _animate_width(self): + """Animate the width change.""" + target_width = self.WIDTH_EXPANDED if self._expanded else self.WIDTH_COLLAPSED + + # Use QPropertyAnimation for smooth width transition + self._width_anim = QPropertyAnimation(self, b"minimumWidth") + self._width_anim.setDuration(DesignTokens.DURATION_NORMAL) + self._width_anim.setStartValue(self.width()) + self._width_anim.setEndValue(target_width) + self._width_anim.setEasingCurve(QEasingCurve.Type.OutCubic) + + self._width_anim2 = QPropertyAnimation(self, b"maximumWidth") + self._width_anim2.setDuration(DesignTokens.DURATION_NORMAL) + self._width_anim2.setStartValue(self.width()) + self._width_anim2.setEndValue(target_width) + self._width_anim2.setEasingCurve(QEasingCurve.Type.OutCubic) + + # Update fixed width when done + self._width_anim2.finished.connect(lambda: self.setFixedWidth(target_width)) + + self._width_anim.start() + self._width_anim2.start() def add_destination(self, icon_name: str, label: str, destination_id: str): """Add a navigation destination with SVG icon.""" btn = NavigationDestination(icon_name, label, destination_id, self.icon_manager) btn.clicked.connect(lambda: self._on_destination_clicked(destination_id)) + btn.set_expanded(self._expanded) self.destinations.append(btn) - self.layout.insertWidget(len(self.destinations) - 1, btn) + self.destinations_layout.addWidget(btn) def _on_destination_clicked(self, destination_id: str): """Handle destination selection.""" @@ -378,59 +463,136 @@ class NavigationRail(QFrame): self.active_destination = destination_id for dest in self.destinations: dest.set_active(dest.destination_id == destination_id) + + def is_expanded(self) -> bool: + """Return current expanded state.""" + return self._expanded class NavigationDestination(QPushButton): - """Single navigation destination with SVG icon.""" + """Single navigation destination with SVG icon and optional label.""" def __init__(self, icon_name: str, label: str, destination_id: str, icon_manager, parent=None): super().__init__(parent) self.destination_id = destination_id self.icon_manager = icon_manager self.icon_name = icon_name - self.label = label + self.label_text = label + self._expanded = False + self._active = False self._setup_style() self._create_content() def _setup_style(self): """Apply destination styling.""" - self.setFixedSize(56, 56) + self.setMinimumHeight(48) self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) self._update_style(False) def _create_content(self): - """Create icon from SVG.""" - self.setIcon(self.icon_manager.get_icon(self.icon_name)) - self.setIconSize(QSize(24, 24)) - self.setToolTip(self.label) + """Create icon and label.""" + # Horizontal layout for icon + label + self.content_layout = QHBoxLayout(self) + self.content_layout.setContentsMargins(12, 8, 12, 8) + self.content_layout.setSpacing(12) + + # Icon + self.icon_label = QLabel() + self.icon_label.setPixmap(self.icon_manager.get_pixmap(self.icon_name, size=24)) + self.content_layout.addWidget(self.icon_label) + + # Text label (hidden when collapsed) + self.text_label = QLabel(self.label_text) + self.text_label.setStyleSheet(""" + QLabel { + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + font-weight: 500; + } + """) + self.text_label.hide() + self.content_layout.addWidget(self.text_label) + + self.content_layout.addStretch() + + self.setToolTip(self.label_text) + + def set_expanded(self, expanded: bool): + """Show or hide the text label.""" + self._expanded = expanded + if expanded: + self.text_label.show() + self.setMinimumWidth(176) # Fill expanded rail width + self.setMaximumWidth(176) + else: + self.text_label.hide() + self.setFixedSize(56, 48) + self._update_style(self._active) def _update_style(self, active: bool): """Update style based on active state with orange accent border.""" - c = get_all_colors() + self._active = active orange = DesignTokens.EU_ORANGE - if active: - self.setStyleSheet(f""" - QPushButton {{ - background: rgba(255, 140, 66, 0.15); - border: none; - border-left: 3px solid {orange}; - border-radius: 0 16px 16px 0; - }} - """) + if self._expanded: + # Expanded style - full width with left border + if active: + self.setStyleSheet(f""" + QPushButton {{ + background: rgba(255, 140, 66, 0.15); + border: none; + border-left: 3px solid {orange}; + border-radius: 0 12px 12px 0; + text-align: left; + padding-left: 9px; + }} + QLabel {{ + color: #ffffff; + font-weight: 600; + }} + """) + else: + self.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: none; + border-left: 3px solid transparent; + border-radius: 0 12px 12px 0; + text-align: left; + padding-left: 9px; + }} + QPushButton:hover {{ + background: rgba(255, 255, 255, 0.05); + border-left: 3px solid rgba(255, 140, 66, 0.3); + }} + QLabel {{ + color: rgba(255, 255, 255, 0.7); + }} + """) else: - self.setStyleSheet(f""" - QPushButton {{ - background: transparent; - border: none; - border-left: 3px solid transparent; - border-radius: 0 16px 16px 0; - }} - QPushButton:hover {{ - background: rgba(255, 255, 255, 0.05); - border-left: 3px solid rgba(255, 140, 66, 0.3); - }} - """) + # Collapsed style - icon only + if active: + self.setStyleSheet(f""" + QPushButton {{ + background: rgba(255, 140, 66, 0.15); + border: none; + border-left: 3px solid {orange}; + border-radius: 0 16px 16px 0; + }} + """) + else: + self.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: none; + border-left: 3px solid transparent; + border-radius: 0 16px 16px 0; + }} + QPushButton:hover {{ + background: rgba(255, 255, 255, 0.05); + border-left: 3px solid rgba(255, 140, 66, 0.3); + }} + """) def set_active(self, active: bool): """Set active state."""