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
This commit is contained in:
LemonNexus 2026-02-16 00:11:43 +00:00
parent 447098b9fa
commit d8a7ab6ba0
1 changed files with 200 additions and 38 deletions

View File

@ -329,21 +329,26 @@ class Card(Surface):
class NavigationRail(QFrame): class NavigationRail(QFrame):
"""Material Design Navigation Rail with EU styling.""" """Material Design Navigation Rail with EU styling - Expandable with labels."""
destination_changed = pyqtSignal(str) destination_changed = pyqtSignal(str)
# Widths for collapsed/expanded states
WIDTH_COLLAPSED = 80
WIDTH_EXPANDED = 200
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.destinations = [] self.destinations = []
self.active_destination = None self.active_destination = None
self.icon_manager = get_icon_manager() self.icon_manager = get_icon_manager()
self._expanded = False
self._setup_style() self._setup_style()
self._setup_layout() self._setup_layout()
def _setup_style(self): def _setup_style(self):
"""Apply navigation rail styling.""" """Apply navigation rail styling."""
self.setFixedWidth(80) self.setFixedWidth(self.WIDTH_COLLAPSED)
self.setStyleSheet(""" self.setStyleSheet("""
NavigationRail { NavigationRail {
background: rgba(20, 31, 35, 0.98); background: rgba(20, 31, 35, 0.98);
@ -352,21 +357,101 @@ class NavigationRail(QFrame):
""") """)
def _setup_layout(self): def _setup_layout(self):
"""Setup vertical layout.""" """Setup vertical layout with expand toggle."""
self.layout = QVBoxLayout(self) self.main_layout = QVBoxLayout(self)
self.layout.setContentsMargins(12, 24, 12, 24) self.main_layout.setContentsMargins(0, 12, 0, 12)
self.layout.setSpacing(8) self.main_layout.setSpacing(0)
self.layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
# 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 # 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): def add_destination(self, icon_name: str, label: str, destination_id: str):
"""Add a navigation destination with SVG icon.""" """Add a navigation destination with SVG icon."""
btn = NavigationDestination(icon_name, label, destination_id, self.icon_manager) btn = NavigationDestination(icon_name, label, destination_id, self.icon_manager)
btn.clicked.connect(lambda: self._on_destination_clicked(destination_id)) btn.clicked.connect(lambda: self._on_destination_clicked(destination_id))
btn.set_expanded(self._expanded)
self.destinations.append(btn) 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): def _on_destination_clicked(self, destination_id: str):
"""Handle destination selection.""" """Handle destination selection."""
@ -379,36 +464,113 @@ class NavigationRail(QFrame):
for dest in self.destinations: for dest in self.destinations:
dest.set_active(dest.destination_id == destination_id) dest.set_active(dest.destination_id == destination_id)
def is_expanded(self) -> bool:
"""Return current expanded state."""
return self._expanded
class NavigationDestination(QPushButton): 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): def __init__(self, icon_name: str, label: str, destination_id: str, icon_manager, parent=None):
super().__init__(parent) super().__init__(parent)
self.destination_id = destination_id self.destination_id = destination_id
self.icon_manager = icon_manager self.icon_manager = icon_manager
self.icon_name = icon_name self.icon_name = icon_name
self.label = label self.label_text = label
self._expanded = False
self._active = False
self._setup_style() self._setup_style()
self._create_content() self._create_content()
def _setup_style(self): def _setup_style(self):
"""Apply destination styling.""" """Apply destination styling."""
self.setFixedSize(56, 56) self.setMinimumHeight(48)
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self._update_style(False) self._update_style(False)
def _create_content(self): def _create_content(self):
"""Create icon from SVG.""" """Create icon and label."""
self.setIcon(self.icon_manager.get_icon(self.icon_name)) # Horizontal layout for icon + label
self.setIconSize(QSize(24, 24)) self.content_layout = QHBoxLayout(self)
self.setToolTip(self.label) 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): def _update_style(self, active: bool):
"""Update style based on active state with orange accent border.""" """Update style based on active state with orange accent border."""
c = get_all_colors() self._active = active
orange = DesignTokens.EU_ORANGE orange = DesignTokens.EU_ORANGE
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:
# Collapsed style - icon only
if active: if active:
self.setStyleSheet(f""" self.setStyleSheet(f"""
QPushButton {{ QPushButton {{