voxkit.gui
PyQt6-based graphical user interface for interacting with datasets using tools. Designed for ease of use and extensibility.
API
- VoxKitGUI: Main application window with toolbar navigation
Submodules
- components/: Reusable widgets (dropdowns, dialogs, toggles)
- frameworks/: UI pattern frameworks (categorical_table, settings_modal)
- pages/: Main application pages (datasets, models, pipeline)
- styles/: Centralized styling (Colors, Buttons, Labels, Containers)
- workers/: QThread background workers for long operations
Notes
- Uses PyQt6 signals/slots for component communication
- Long operations run in QThread workers to avoid UI blocking
- Styling is centralized in the styles module for consistency
1"""PyQt6-based graphical user interface for interacting with datasets using tools. 2Designed for ease of use and extensibility. 3 4API 5--- 6- **VoxKitGUI**: Main application window with toolbar navigation 7 8Submodules 9---------- 10- **components/**: Reusable widgets (dropdowns, dialogs, toggles) 11- **frameworks/**: UI pattern frameworks (categorical_table, settings_modal) 12- **pages/**: Main application pages (datasets, models, pipeline) 13- **styles/**: Centralized styling (Colors, Buttons, Labels, Containers) 14- **workers/**: QThread background workers for long operations 15 16Notes 17----- 18- Uses PyQt6 signals/slots for component communication 19- Long operations run in QThread workers to avoid UI blocking 20- Styling is centralized in the styles module for consistency 21""" 22 23import logging 24import webbrowser 25from typing import Optional 26from urllib.parse import quote 27 28from PyQt6.QtCore import Qt 29from PyQt6.QtGui import QAction, QIcon 30from PyQt6.QtWidgets import ( 31 QHBoxLayout, 32 QMainWindow, 33 QStackedWidget, 34 QToolBar, 35 QToolButton, 36 QWidget, 37) 38from rich import print as rprint 39 40from voxkit.config.app_config import AppConfig, get_app_config 41from voxkit.config.pipeline_config import PipelineConfig, get_pipeline_config 42from voxkit.gui.components import DNAStrandWidget, LogViewerDialog 43from voxkit.gui.pages.datasets import DatasetsPage 44from voxkit.gui.pages.models import ManageAlignersWidget 45from voxkit.gui.pages.pipeline import PipelineFormStack as PipelineContainer 46 47logger = logging.getLogger(__name__) 48 49FEEDBACK_SUBJECT = "VoxKit Feedback" 50FEEDBACK_BODY_TEMPLATE = ( 51 "Please share your feedback below.\n\n" 52 "What were you trying to do?\n" 53 "- \n\n" 54 "What happened?\n" 55 "- \n\n" 56 "What did you expect instead?\n" 57 "- \n\n" 58 "Additional context:\n" 59 "- \n" 60) 61 62 63def build_feedback_mailto_url( 64 recipient: str, 65 subject: str = FEEDBACK_SUBJECT, 66 body: str = FEEDBACK_BODY_TEMPLATE, 67) -> str: 68 encoded_subject = quote(subject, safe="") 69 encoded_body = quote(body, safe="") 70 return f"mailto:{recipient}?subject={encoded_subject}&body={encoded_body}" 71 72 73GlobalStyleSheet = """ 74 QMainWindow { 75 background-color: transparent; 76 } 77 QWidget { 78 background-color: #f5f5f5; 79 color: #333; 80 font-size: 13px; 81 border: none; 82 } 83 QGroupBox { 84 background-color: white; 85 border: 1px solid #e0e0e0; 86 border-radius: 8px; 87 margin-top: 10px; 88 padding: 15px; 89 } 90 QLabel { 91 color: #333; 92 background-color: transparent; 93 } 94 QLineEdit { 95 background-color: white; 96 border: 1px solid #d0d0d0; 97 border-radius: 5px; 98 padding: 8px 12px; 99 min-height: 20px; 100 color: #333; 101 } 102 QLineEdit:focus { 103 border: 2px solid #4a90e2; 104 } 105 QPushButton#primaryButton { 106 background-color: white; 107 border: 1px solid #d0d0d0; 108 border-radius: 5px; 109 padding: 8px 16px; 110 min-width: 80px; 111 min-height: 20px; 112 color: #333; 113 } 114 QPushButton:hover { 115 background-color: #f0f0f0; 116 border-color: #b0b0b0; 117 } 118 QPushButton:pressed { 119 background-color: #e0e0e0; 120 } 121 QRadioButton { 122 background-color: white; 123 color: #333; 124 spacing: 8px; 125 } 126 QRadioButton::indicator { 127 width: 18px; 128 height: 18px; 129 border-radius: 9px; 130 } 131 QRadioButton::indicator:unchecked { 132 border: 2px solid #d0d0d0; 133 background-color: white; 134 } 135 QRadioButton::indicator:checked { 136 border: 2px solid #4a90e2; 137 background-color: #4a90e2; 138 } 139 QRadioButton::indicator:hover { 140 border-color: #4a90e2; 141 } 142 QListWidget { 143 background-color: white; 144 border: 1px solid #e0e0e0; 145 border-radius: 8px; 146 padding: 5px; 147 outline: none; 148 } 149 QListWidget::item { 150 padding: 12px 15px; 151 border-radius: 5px; 152 color: #333; 153 } 154 QListWidget::item:selected { 155 background-color: #4a90e2; 156 color: white; 157 } 158 QListWidget::item:hover { 159 background-color: #b0cef2; 160 } 161 QWidget#centralWidget { 162 background-color: #f5f7fa; 163 } 164 165 """ 166 167ToolBarStyle = """ 168 QToolBar#globalToolbar { 169 background: #2f3542; 170 spacing: 6px; 171 padding: 4px; 172 } 173 QToolBar#globalToolbar QToolButton { 174 color: #eceff4; 175 background: transparent; 176 border: 1px solid transparent; 177 padding: 6px 10px; 178 border-radius: 6px; 179 margin: 2px; 180 } 181 QToolBar#globalToolbar QToolButton:hover { 182 background: #3b4252; 183 border-color: #4c566a; 184 } 185 QToolBar#globalToolbar QToolButton:pressed { 186 background: #2b6fa2; 187 } 188 QToolBar#globalToolbar QToolButton:disabled { 189 color: #7f8c8d; 190 } 191 QToolBar#globalToolbar QToolButton#feedbackButton { 192 color: #f0c674; 193 background: transparent; 194 border: 1px solid #d9a441; 195 font-weight: bold; 196 padding: 6px 14px; 197 } 198 QToolBar#globalToolbar QToolButton#feedbackButton:hover { 199 background: #3b4252; 200 border-color: #f0c674; 201 } 202 QToolBar#globalToolbar QToolButton#feedbackButton:pressed { 203 background: #2b323d; 204 border-color: #f0c674; 205 } 206 """ 207 208 209class VoxKitGUI(QMainWindow): 210 def __init__( 211 self, 212 app_config: Optional[AppConfig] = None, 213 pipeline_config: Optional[PipelineConfig] = None, 214 ): 215 """Initialize the VoxKitGUI. 216 217 Args: 218 app_config: Application configuration. If None, loads default from config files. 219 pipeline_config: Pipeline configuration. If None, loads default from config files. 220 """ 221 super().__init__() 222 223 # Load configurations (use provided or load defaults) 224 self.app_config = app_config or get_app_config() 225 self.pipeline_config = pipeline_config or get_pipeline_config() 226 227 logger.info( 228 "VoxKitGUI initialized: app=%s version=%s", 229 self.app_config.app_name, 230 self.app_config.version, 231 ) 232 233 # DEBUG 234 rprint("[bold green]App Configuration:[/bold green]") 235 rprint(self.app_config) 236 rprint("\n[bold green]Pipeline Configuration:[/bold green]") 237 rprint(self.pipeline_config) 238 239 # Create the toolbar 240 toolbar = QToolBar("Global Toolbar") 241 toolbar.setMovable(False) 242 toolbar.setFloatable(False) 243 self.addToolBar(toolbar) 244 # Add actions (buttons) 245 self.add_global_actions(toolbar) 246 self.init_ui() 247 248 def add_global_actions(self, toolbar): 249 # Give the toolbar an object name so stylesheet rules can target it 250 toolbar.setObjectName("globalToolbar") 251 toolbar.setMovable(False) 252 # Apply stylesheet for toolbar and its tool buttons 253 toolbar.setStyleSheet(ToolBarStyle) 254 255 # Helper to add an action and apply some per-button properties 256 def _add_button(text, callback, tooltip=None, icon=QIcon(), object_name=None): 257 action = QAction(icon, text, self) 258 if tooltip: 259 action.setToolTip(tooltip) 260 action.triggered.connect(callback) 261 toolbar.addAction(action) 262 # style the concrete tool button widget if available 263 widget = toolbar.widgetForAction(action) 264 265 if widget is not None: 266 widget.setCursor(widget.cursor()) # ensure widget exists; can set more props here 267 if object_name: 268 # Naming the widget lets ToolBarStyle target it individually; 269 # re-polish so the new rule is applied to the already-styled toolbar. 270 widget.setObjectName(object_name) 271 widget.style().unpolish(widget) 272 widget.style().polish(widget) 273 return action 274 275 # Store actions for Pipeline, Datasets, Manage so we can update their styles 276 self.pipeline_action = _add_button( 277 "Pipeline", self.open_models_dashboard, tooltip="Main Pipeline Dashboard" 278 ) 279 self.datasets_action = _add_button( 280 "Datasets", self.open_datasets, tooltip="Manage Datasets" 281 ) 282 self.manage_action = _add_button( 283 "Models", self.open_preferences, tooltip="Manage Aligner Models" 284 ) 285 # Help button 286 _add_button("Help", self.open_help, tooltip="Get Help") 287 288 # Store toolbar reference for updating button styles 289 self.toolbar = toolbar 290 291 # Add spacer to push DNA widget to fill remaining space 292 spacer = QWidget() 293 spacer.setSizePolicy( 294 spacer.sizePolicy().horizontalPolicy(), spacer.sizePolicy().verticalPolicy() 295 ) 296 toolbar.addWidget(spacer) 297 298 # Add decorative DNA strand widget 299 self.dna_widget = DNAStrandWidget() 300 toolbar.addWidget(self.dna_widget) 301 302 # Prominent, standalone Feedback button pinned to the far right so it 303 # stands apart from the page-navigation tabs and is impossible to miss. 304 _add_button( 305 "💬 Send Feedback", 306 self.open_feedback, 307 tooltip="Send Feedback — tell us what's working or what's not", 308 object_name="feedbackButton", 309 ) 310 311 def update_active_tab_style(self, active_button): 312 """Update the styling to show which tab is active""" 313 # Style for active tab - gradient blending from toolbar to content background 314 active_style = """ 315 background: qlineargradient( 316 x1:0, y1:0, x2:0, y2:1, 317 stop:0 #2f3542, 318 stop:1 #f5f7fa 319 ); 320 border: 1px solid transparent; 321 border-bottom: none; 322 border-radius: 6px 6px 0px 0px; 323 padding: 6px 10px; 324 margin: 2px; 325 margin-bottom: 0px; 326 color: #25282F; /* Dark text for active tab */ 327 """ 328 329 # Style for inactive tabs 330 inactive_style = """ 331 color: #eceff4; 332 background: transparent; 333 border: 1px solid transparent; 334 padding: 6px 10px; 335 border-radius: 6px; 336 margin: 2px; 337 """ 338 339 # Get the actual button widgets 340 datasets_widget = self.toolbar.widgetForAction(self.datasets_action) 341 pipeline_widget = self.toolbar.widgetForAction(self.pipeline_action) 342 manage_widget = self.toolbar.widgetForAction(self.manage_action) 343 344 # Apply styles based on which button is active 345 if active_button == "datasets": 346 if datasets_widget: 347 datasets_widget.setStyleSheet(active_style) 348 if pipeline_widget: 349 pipeline_widget.setStyleSheet(inactive_style) 350 if manage_widget: 351 manage_widget.setStyleSheet(inactive_style) 352 elif active_button == "pipeline": 353 if datasets_widget: 354 datasets_widget.setStyleSheet(inactive_style) 355 if pipeline_widget: 356 pipeline_widget.setStyleSheet(active_style) 357 if manage_widget: 358 manage_widget.setStyleSheet(inactive_style) 359 elif active_button == "manage": 360 if datasets_widget: 361 datasets_widget.setStyleSheet(inactive_style) 362 if pipeline_widget: 363 pipeline_widget.setStyleSheet(inactive_style) 364 if manage_widget: 365 manage_widget.setStyleSheet(active_style) 366 367 def open_datasets(self): 368 """Switch to Datasets view""" 369 logger.info("Navigate: Datasets page") 370 # Remember current pipeline page 371 self.last_pipeline_page = self.pipeline_container.get_current_page_index() 372 self.pipeline_container.menu_list.setVisible(False) 373 self.content_stack.setCurrentIndex(1) # Show datasets page 374 # Refresh the selected dataset to show any new alignments 375 self.datasets_page.refresh_selected_dataset() 376 # Update active tab styling 377 self.update_active_tab_style("datasets") 378 379 def open_models_dashboard(self): 380 """Switch to Pipeline view with menu and stacked pages""" 381 logger.info("Navigate: Pipeline page") 382 self.pipeline_container.reload() # Ensure models are reloaded 383 self.pipeline_container.menu_list.setVisible(True) 384 self.content_stack.setCurrentIndex(0) # Show pipeline stack 385 # Restore last selected pipeline page 386 self.pipeline_container.set_current_page_index(self.last_pipeline_page) 387 # Update active tab styling 388 self.update_active_tab_style("pipeline") 389 390 def open_preferences(self): 391 """Switch to Manage view with CategoricalListWidget""" 392 logger.info("Navigate: Models page") 393 self.pipeline_container.reload() # Ensure models are reloaded 394 # Remember current pipeline page 395 self.last_pipeline_page = self.pipeline_container.get_current_page_index() 396 self.pipeline_container.menu_list.setVisible(False) 397 self.content_stack.setCurrentIndex(2) # Show manage widget 398 # Update active tab styling 399 self.update_active_tab_style("manage") 400 401 def open_help(self): 402 logger.info("Opening help URL: %s", self.app_config.help_url) 403 webbrowser.open(self.app_config.help_url) 404 405 def open_feedback(self): 406 if not self.app_config.feedback_email: 407 logger.warning("Feedback email is not configured") 408 return 409 mailto_url = build_feedback_mailto_url(self.app_config.feedback_email) 410 logger.info("Opening feedback email compose window") 411 webbrowser.open(mailto_url) 412 413 def init_ui(self): 414 self.setWindowTitle(self.app_config.app_name) 415 self.setMinimumSize(1200, 800) 416 417 # Set application-wide stylesheet 418 self.setStyleSheet(GlobalStyleSheet) 419 420 # Track last pipeline page 421 self.last_pipeline_page = 0 422 423 # Central widget and main layout 424 central_widget = QWidget() 425 central_widget.setObjectName("centralWidget") 426 self.setCentralWidget(central_widget) 427 main_layout = QHBoxLayout(central_widget) 428 main_layout.setSpacing(20) 429 main_layout.setContentsMargins(20, 20, 20, 20) 430 431 # Master stacked widget to switch between Pipeline, Datasets and Manage views 432 self.content_stack = QStackedWidget() 433 main_layout.addWidget(self.content_stack, stretch=1) 434 435 # Pipeline view: container with menu and animated stacked widget 436 self.pipeline_container = PipelineContainer(self, config=self.pipeline_config) 437 self.content_stack.addWidget(self.pipeline_container) 438 439 # Datasets view: dataset management page 440 self.datasets_page = DatasetsPage(self) 441 self.content_stack.addWidget(self.datasets_page) 442 443 # Manage view: categorical list widget 444 self.manage_widget = ManageAlignersWidget(self) 445 self.content_stack.addWidget(self.manage_widget) 446 447 # Start with Pipeline view 448 self.content_stack.setCurrentIndex(0) 449 450 # Set initial active tab style 451 self.update_active_tab_style("pipeline") 452 453 # Subtle status-bar entry point for the log viewer 454 self._init_log_status_entry() 455 456 def _init_log_status_entry(self) -> None: 457 """Attach a low-visibility log viewer button floating in the bottom-right.""" 458 central = self.centralWidget() 459 if central is None: 460 return 461 462 self._log_button = QToolButton(central) 463 self._log_button.setText("\u2630") # trigram glyph — subtle, monochrome 464 self._log_button.setToolTip("View application log") 465 self._log_button.setCursor(Qt.CursorShape.PointingHandCursor) 466 self._log_button.setStyleSheet( 467 "QToolButton {" 468 " background: transparent;" 469 " color: #9aa0a6;" 470 " border: none;" 471 " padding: 0 4px;" 472 " font-size: 12px;" 473 "}" 474 "QToolButton:hover { color: #5f6368; }" 475 ) 476 self._log_button.clicked.connect(self._open_log_viewer) 477 self._log_button.adjustSize() 478 self._log_button.raise_() 479 self._log_viewer: Optional[LogViewerDialog] = None 480 481 central.installEventFilter(self) 482 self._reposition_log_button() 483 484 def _reposition_log_button(self) -> None: 485 central = self.centralWidget() 486 if central is None or not hasattr(self, "_log_button"): 487 return 488 btn = self._log_button 489 btn.adjustSize() 490 # Bottom-right, aligned to the existing 20px content margin. 491 x = central.width() - btn.width() - 4 492 y = central.height() - btn.height() - 4 493 btn.move(max(0, x), max(0, y)) 494 495 def eventFilter(self, obj, event): # noqa: N802 (Qt API) 496 from PyQt6.QtCore import QEvent 497 498 if obj is self.centralWidget() and event.type() in ( 499 QEvent.Type.Resize, 500 QEvent.Type.Show, 501 ): 502 self._reposition_log_button() 503 return super().eventFilter(obj, event) 504 505 def _open_log_viewer(self) -> None: 506 logger.info("Opening log viewer") 507 if self._log_viewer is None or not self._log_viewer.isVisible(): 508 self._log_viewer = LogViewerDialog(self) 509 self._log_viewer.show() 510 else: 511 self._log_viewer.raise_() 512 self._log_viewer.activateWindow() 513 514 515__all__ = ["VoxKitGUI", "build_feedback_mailto_url"]
class
VoxKitGUI(PyQt6.QtWidgets.QMainWindow):
210class VoxKitGUI(QMainWindow): 211 def __init__( 212 self, 213 app_config: Optional[AppConfig] = None, 214 pipeline_config: Optional[PipelineConfig] = None, 215 ): 216 """Initialize the VoxKitGUI. 217 218 Args: 219 app_config: Application configuration. If None, loads default from config files. 220 pipeline_config: Pipeline configuration. If None, loads default from config files. 221 """ 222 super().__init__() 223 224 # Load configurations (use provided or load defaults) 225 self.app_config = app_config or get_app_config() 226 self.pipeline_config = pipeline_config or get_pipeline_config() 227 228 logger.info( 229 "VoxKitGUI initialized: app=%s version=%s", 230 self.app_config.app_name, 231 self.app_config.version, 232 ) 233 234 # DEBUG 235 rprint("[bold green]App Configuration:[/bold green]") 236 rprint(self.app_config) 237 rprint("\n[bold green]Pipeline Configuration:[/bold green]") 238 rprint(self.pipeline_config) 239 240 # Create the toolbar 241 toolbar = QToolBar("Global Toolbar") 242 toolbar.setMovable(False) 243 toolbar.setFloatable(False) 244 self.addToolBar(toolbar) 245 # Add actions (buttons) 246 self.add_global_actions(toolbar) 247 self.init_ui() 248 249 def add_global_actions(self, toolbar): 250 # Give the toolbar an object name so stylesheet rules can target it 251 toolbar.setObjectName("globalToolbar") 252 toolbar.setMovable(False) 253 # Apply stylesheet for toolbar and its tool buttons 254 toolbar.setStyleSheet(ToolBarStyle) 255 256 # Helper to add an action and apply some per-button properties 257 def _add_button(text, callback, tooltip=None, icon=QIcon(), object_name=None): 258 action = QAction(icon, text, self) 259 if tooltip: 260 action.setToolTip(tooltip) 261 action.triggered.connect(callback) 262 toolbar.addAction(action) 263 # style the concrete tool button widget if available 264 widget = toolbar.widgetForAction(action) 265 266 if widget is not None: 267 widget.setCursor(widget.cursor()) # ensure widget exists; can set more props here 268 if object_name: 269 # Naming the widget lets ToolBarStyle target it individually; 270 # re-polish so the new rule is applied to the already-styled toolbar. 271 widget.setObjectName(object_name) 272 widget.style().unpolish(widget) 273 widget.style().polish(widget) 274 return action 275 276 # Store actions for Pipeline, Datasets, Manage so we can update their styles 277 self.pipeline_action = _add_button( 278 "Pipeline", self.open_models_dashboard, tooltip="Main Pipeline Dashboard" 279 ) 280 self.datasets_action = _add_button( 281 "Datasets", self.open_datasets, tooltip="Manage Datasets" 282 ) 283 self.manage_action = _add_button( 284 "Models", self.open_preferences, tooltip="Manage Aligner Models" 285 ) 286 # Help button 287 _add_button("Help", self.open_help, tooltip="Get Help") 288 289 # Store toolbar reference for updating button styles 290 self.toolbar = toolbar 291 292 # Add spacer to push DNA widget to fill remaining space 293 spacer = QWidget() 294 spacer.setSizePolicy( 295 spacer.sizePolicy().horizontalPolicy(), spacer.sizePolicy().verticalPolicy() 296 ) 297 toolbar.addWidget(spacer) 298 299 # Add decorative DNA strand widget 300 self.dna_widget = DNAStrandWidget() 301 toolbar.addWidget(self.dna_widget) 302 303 # Prominent, standalone Feedback button pinned to the far right so it 304 # stands apart from the page-navigation tabs and is impossible to miss. 305 _add_button( 306 "💬 Send Feedback", 307 self.open_feedback, 308 tooltip="Send Feedback — tell us what's working or what's not", 309 object_name="feedbackButton", 310 ) 311 312 def update_active_tab_style(self, active_button): 313 """Update the styling to show which tab is active""" 314 # Style for active tab - gradient blending from toolbar to content background 315 active_style = """ 316 background: qlineargradient( 317 x1:0, y1:0, x2:0, y2:1, 318 stop:0 #2f3542, 319 stop:1 #f5f7fa 320 ); 321 border: 1px solid transparent; 322 border-bottom: none; 323 border-radius: 6px 6px 0px 0px; 324 padding: 6px 10px; 325 margin: 2px; 326 margin-bottom: 0px; 327 color: #25282F; /* Dark text for active tab */ 328 """ 329 330 # Style for inactive tabs 331 inactive_style = """ 332 color: #eceff4; 333 background: transparent; 334 border: 1px solid transparent; 335 padding: 6px 10px; 336 border-radius: 6px; 337 margin: 2px; 338 """ 339 340 # Get the actual button widgets 341 datasets_widget = self.toolbar.widgetForAction(self.datasets_action) 342 pipeline_widget = self.toolbar.widgetForAction(self.pipeline_action) 343 manage_widget = self.toolbar.widgetForAction(self.manage_action) 344 345 # Apply styles based on which button is active 346 if active_button == "datasets": 347 if datasets_widget: 348 datasets_widget.setStyleSheet(active_style) 349 if pipeline_widget: 350 pipeline_widget.setStyleSheet(inactive_style) 351 if manage_widget: 352 manage_widget.setStyleSheet(inactive_style) 353 elif active_button == "pipeline": 354 if datasets_widget: 355 datasets_widget.setStyleSheet(inactive_style) 356 if pipeline_widget: 357 pipeline_widget.setStyleSheet(active_style) 358 if manage_widget: 359 manage_widget.setStyleSheet(inactive_style) 360 elif active_button == "manage": 361 if datasets_widget: 362 datasets_widget.setStyleSheet(inactive_style) 363 if pipeline_widget: 364 pipeline_widget.setStyleSheet(inactive_style) 365 if manage_widget: 366 manage_widget.setStyleSheet(active_style) 367 368 def open_datasets(self): 369 """Switch to Datasets view""" 370 logger.info("Navigate: Datasets page") 371 # Remember current pipeline page 372 self.last_pipeline_page = self.pipeline_container.get_current_page_index() 373 self.pipeline_container.menu_list.setVisible(False) 374 self.content_stack.setCurrentIndex(1) # Show datasets page 375 # Refresh the selected dataset to show any new alignments 376 self.datasets_page.refresh_selected_dataset() 377 # Update active tab styling 378 self.update_active_tab_style("datasets") 379 380 def open_models_dashboard(self): 381 """Switch to Pipeline view with menu and stacked pages""" 382 logger.info("Navigate: Pipeline page") 383 self.pipeline_container.reload() # Ensure models are reloaded 384 self.pipeline_container.menu_list.setVisible(True) 385 self.content_stack.setCurrentIndex(0) # Show pipeline stack 386 # Restore last selected pipeline page 387 self.pipeline_container.set_current_page_index(self.last_pipeline_page) 388 # Update active tab styling 389 self.update_active_tab_style("pipeline") 390 391 def open_preferences(self): 392 """Switch to Manage view with CategoricalListWidget""" 393 logger.info("Navigate: Models page") 394 self.pipeline_container.reload() # Ensure models are reloaded 395 # Remember current pipeline page 396 self.last_pipeline_page = self.pipeline_container.get_current_page_index() 397 self.pipeline_container.menu_list.setVisible(False) 398 self.content_stack.setCurrentIndex(2) # Show manage widget 399 # Update active tab styling 400 self.update_active_tab_style("manage") 401 402 def open_help(self): 403 logger.info("Opening help URL: %s", self.app_config.help_url) 404 webbrowser.open(self.app_config.help_url) 405 406 def open_feedback(self): 407 if not self.app_config.feedback_email: 408 logger.warning("Feedback email is not configured") 409 return 410 mailto_url = build_feedback_mailto_url(self.app_config.feedback_email) 411 logger.info("Opening feedback email compose window") 412 webbrowser.open(mailto_url) 413 414 def init_ui(self): 415 self.setWindowTitle(self.app_config.app_name) 416 self.setMinimumSize(1200, 800) 417 418 # Set application-wide stylesheet 419 self.setStyleSheet(GlobalStyleSheet) 420 421 # Track last pipeline page 422 self.last_pipeline_page = 0 423 424 # Central widget and main layout 425 central_widget = QWidget() 426 central_widget.setObjectName("centralWidget") 427 self.setCentralWidget(central_widget) 428 main_layout = QHBoxLayout(central_widget) 429 main_layout.setSpacing(20) 430 main_layout.setContentsMargins(20, 20, 20, 20) 431 432 # Master stacked widget to switch between Pipeline, Datasets and Manage views 433 self.content_stack = QStackedWidget() 434 main_layout.addWidget(self.content_stack, stretch=1) 435 436 # Pipeline view: container with menu and animated stacked widget 437 self.pipeline_container = PipelineContainer(self, config=self.pipeline_config) 438 self.content_stack.addWidget(self.pipeline_container) 439 440 # Datasets view: dataset management page 441 self.datasets_page = DatasetsPage(self) 442 self.content_stack.addWidget(self.datasets_page) 443 444 # Manage view: categorical list widget 445 self.manage_widget = ManageAlignersWidget(self) 446 self.content_stack.addWidget(self.manage_widget) 447 448 # Start with Pipeline view 449 self.content_stack.setCurrentIndex(0) 450 451 # Set initial active tab style 452 self.update_active_tab_style("pipeline") 453 454 # Subtle status-bar entry point for the log viewer 455 self._init_log_status_entry() 456 457 def _init_log_status_entry(self) -> None: 458 """Attach a low-visibility log viewer button floating in the bottom-right.""" 459 central = self.centralWidget() 460 if central is None: 461 return 462 463 self._log_button = QToolButton(central) 464 self._log_button.setText("\u2630") # trigram glyph — subtle, monochrome 465 self._log_button.setToolTip("View application log") 466 self._log_button.setCursor(Qt.CursorShape.PointingHandCursor) 467 self._log_button.setStyleSheet( 468 "QToolButton {" 469 " background: transparent;" 470 " color: #9aa0a6;" 471 " border: none;" 472 " padding: 0 4px;" 473 " font-size: 12px;" 474 "}" 475 "QToolButton:hover { color: #5f6368; }" 476 ) 477 self._log_button.clicked.connect(self._open_log_viewer) 478 self._log_button.adjustSize() 479 self._log_button.raise_() 480 self._log_viewer: Optional[LogViewerDialog] = None 481 482 central.installEventFilter(self) 483 self._reposition_log_button() 484 485 def _reposition_log_button(self) -> None: 486 central = self.centralWidget() 487 if central is None or not hasattr(self, "_log_button"): 488 return 489 btn = self._log_button 490 btn.adjustSize() 491 # Bottom-right, aligned to the existing 20px content margin. 492 x = central.width() - btn.width() - 4 493 y = central.height() - btn.height() - 4 494 btn.move(max(0, x), max(0, y)) 495 496 def eventFilter(self, obj, event): # noqa: N802 (Qt API) 497 from PyQt6.QtCore import QEvent 498 499 if obj is self.centralWidget() and event.type() in ( 500 QEvent.Type.Resize, 501 QEvent.Type.Show, 502 ): 503 self._reposition_log_button() 504 return super().eventFilter(obj, event) 505 506 def _open_log_viewer(self) -> None: 507 logger.info("Opening log viewer") 508 if self._log_viewer is None or not self._log_viewer.isVisible(): 509 self._log_viewer = LogViewerDialog(self) 510 self._log_viewer.show() 511 else: 512 self._log_viewer.raise_() 513 self._log_viewer.activateWindow()
QMainWindow(parent: QWidget|None = None, flags: Qt.WindowType = Qt.WindowFlags())
VoxKitGUI( app_config: Optional[voxkit.config.AppConfig] = None, pipeline_config: Optional[voxkit.config.PipelineConfig] = None)
211 def __init__( 212 self, 213 app_config: Optional[AppConfig] = None, 214 pipeline_config: Optional[PipelineConfig] = None, 215 ): 216 """Initialize the VoxKitGUI. 217 218 Args: 219 app_config: Application configuration. If None, loads default from config files. 220 pipeline_config: Pipeline configuration. If None, loads default from config files. 221 """ 222 super().__init__() 223 224 # Load configurations (use provided or load defaults) 225 self.app_config = app_config or get_app_config() 226 self.pipeline_config = pipeline_config or get_pipeline_config() 227 228 logger.info( 229 "VoxKitGUI initialized: app=%s version=%s", 230 self.app_config.app_name, 231 self.app_config.version, 232 ) 233 234 # DEBUG 235 rprint("[bold green]App Configuration:[/bold green]") 236 rprint(self.app_config) 237 rprint("\n[bold green]Pipeline Configuration:[/bold green]") 238 rprint(self.pipeline_config) 239 240 # Create the toolbar 241 toolbar = QToolBar("Global Toolbar") 242 toolbar.setMovable(False) 243 toolbar.setFloatable(False) 244 self.addToolBar(toolbar) 245 # Add actions (buttons) 246 self.add_global_actions(toolbar) 247 self.init_ui()
Initialize the VoxKitGUI.
Args: app_config: Application configuration. If None, loads default from config files. pipeline_config: Pipeline configuration. If None, loads default from config files.
def
add_global_actions(self, toolbar):
249 def add_global_actions(self, toolbar): 250 # Give the toolbar an object name so stylesheet rules can target it 251 toolbar.setObjectName("globalToolbar") 252 toolbar.setMovable(False) 253 # Apply stylesheet for toolbar and its tool buttons 254 toolbar.setStyleSheet(ToolBarStyle) 255 256 # Helper to add an action and apply some per-button properties 257 def _add_button(text, callback, tooltip=None, icon=QIcon(), object_name=None): 258 action = QAction(icon, text, self) 259 if tooltip: 260 action.setToolTip(tooltip) 261 action.triggered.connect(callback) 262 toolbar.addAction(action) 263 # style the concrete tool button widget if available 264 widget = toolbar.widgetForAction(action) 265 266 if widget is not None: 267 widget.setCursor(widget.cursor()) # ensure widget exists; can set more props here 268 if object_name: 269 # Naming the widget lets ToolBarStyle target it individually; 270 # re-polish so the new rule is applied to the already-styled toolbar. 271 widget.setObjectName(object_name) 272 widget.style().unpolish(widget) 273 widget.style().polish(widget) 274 return action 275 276 # Store actions for Pipeline, Datasets, Manage so we can update their styles 277 self.pipeline_action = _add_button( 278 "Pipeline", self.open_models_dashboard, tooltip="Main Pipeline Dashboard" 279 ) 280 self.datasets_action = _add_button( 281 "Datasets", self.open_datasets, tooltip="Manage Datasets" 282 ) 283 self.manage_action = _add_button( 284 "Models", self.open_preferences, tooltip="Manage Aligner Models" 285 ) 286 # Help button 287 _add_button("Help", self.open_help, tooltip="Get Help") 288 289 # Store toolbar reference for updating button styles 290 self.toolbar = toolbar 291 292 # Add spacer to push DNA widget to fill remaining space 293 spacer = QWidget() 294 spacer.setSizePolicy( 295 spacer.sizePolicy().horizontalPolicy(), spacer.sizePolicy().verticalPolicy() 296 ) 297 toolbar.addWidget(spacer) 298 299 # Add decorative DNA strand widget 300 self.dna_widget = DNAStrandWidget() 301 toolbar.addWidget(self.dna_widget) 302 303 # Prominent, standalone Feedback button pinned to the far right so it 304 # stands apart from the page-navigation tabs and is impossible to miss. 305 _add_button( 306 "💬 Send Feedback", 307 self.open_feedback, 308 tooltip="Send Feedback — tell us what's working or what's not", 309 object_name="feedbackButton", 310 )
def
update_active_tab_style(self, active_button):
312 def update_active_tab_style(self, active_button): 313 """Update the styling to show which tab is active""" 314 # Style for active tab - gradient blending from toolbar to content background 315 active_style = """ 316 background: qlineargradient( 317 x1:0, y1:0, x2:0, y2:1, 318 stop:0 #2f3542, 319 stop:1 #f5f7fa 320 ); 321 border: 1px solid transparent; 322 border-bottom: none; 323 border-radius: 6px 6px 0px 0px; 324 padding: 6px 10px; 325 margin: 2px; 326 margin-bottom: 0px; 327 color: #25282F; /* Dark text for active tab */ 328 """ 329 330 # Style for inactive tabs 331 inactive_style = """ 332 color: #eceff4; 333 background: transparent; 334 border: 1px solid transparent; 335 padding: 6px 10px; 336 border-radius: 6px; 337 margin: 2px; 338 """ 339 340 # Get the actual button widgets 341 datasets_widget = self.toolbar.widgetForAction(self.datasets_action) 342 pipeline_widget = self.toolbar.widgetForAction(self.pipeline_action) 343 manage_widget = self.toolbar.widgetForAction(self.manage_action) 344 345 # Apply styles based on which button is active 346 if active_button == "datasets": 347 if datasets_widget: 348 datasets_widget.setStyleSheet(active_style) 349 if pipeline_widget: 350 pipeline_widget.setStyleSheet(inactive_style) 351 if manage_widget: 352 manage_widget.setStyleSheet(inactive_style) 353 elif active_button == "pipeline": 354 if datasets_widget: 355 datasets_widget.setStyleSheet(inactive_style) 356 if pipeline_widget: 357 pipeline_widget.setStyleSheet(active_style) 358 if manage_widget: 359 manage_widget.setStyleSheet(inactive_style) 360 elif active_button == "manage": 361 if datasets_widget: 362 datasets_widget.setStyleSheet(inactive_style) 363 if pipeline_widget: 364 pipeline_widget.setStyleSheet(inactive_style) 365 if manage_widget: 366 manage_widget.setStyleSheet(active_style)
Update the styling to show which tab is active
def
open_datasets(self):
368 def open_datasets(self): 369 """Switch to Datasets view""" 370 logger.info("Navigate: Datasets page") 371 # Remember current pipeline page 372 self.last_pipeline_page = self.pipeline_container.get_current_page_index() 373 self.pipeline_container.menu_list.setVisible(False) 374 self.content_stack.setCurrentIndex(1) # Show datasets page 375 # Refresh the selected dataset to show any new alignments 376 self.datasets_page.refresh_selected_dataset() 377 # Update active tab styling 378 self.update_active_tab_style("datasets")
Switch to Datasets view
def
open_models_dashboard(self):
380 def open_models_dashboard(self): 381 """Switch to Pipeline view with menu and stacked pages""" 382 logger.info("Navigate: Pipeline page") 383 self.pipeline_container.reload() # Ensure models are reloaded 384 self.pipeline_container.menu_list.setVisible(True) 385 self.content_stack.setCurrentIndex(0) # Show pipeline stack 386 # Restore last selected pipeline page 387 self.pipeline_container.set_current_page_index(self.last_pipeline_page) 388 # Update active tab styling 389 self.update_active_tab_style("pipeline")
Switch to Pipeline view with menu and stacked pages
def
open_preferences(self):
391 def open_preferences(self): 392 """Switch to Manage view with CategoricalListWidget""" 393 logger.info("Navigate: Models page") 394 self.pipeline_container.reload() # Ensure models are reloaded 395 # Remember current pipeline page 396 self.last_pipeline_page = self.pipeline_container.get_current_page_index() 397 self.pipeline_container.menu_list.setVisible(False) 398 self.content_stack.setCurrentIndex(2) # Show manage widget 399 # Update active tab styling 400 self.update_active_tab_style("manage")
Switch to Manage view with CategoricalListWidget
def
open_feedback(self):
406 def open_feedback(self): 407 if not self.app_config.feedback_email: 408 logger.warning("Feedback email is not configured") 409 return 410 mailto_url = build_feedback_mailto_url(self.app_config.feedback_email) 411 logger.info("Opening feedback email compose window") 412 webbrowser.open(mailto_url)
def
init_ui(self):
414 def init_ui(self): 415 self.setWindowTitle(self.app_config.app_name) 416 self.setMinimumSize(1200, 800) 417 418 # Set application-wide stylesheet 419 self.setStyleSheet(GlobalStyleSheet) 420 421 # Track last pipeline page 422 self.last_pipeline_page = 0 423 424 # Central widget and main layout 425 central_widget = QWidget() 426 central_widget.setObjectName("centralWidget") 427 self.setCentralWidget(central_widget) 428 main_layout = QHBoxLayout(central_widget) 429 main_layout.setSpacing(20) 430 main_layout.setContentsMargins(20, 20, 20, 20) 431 432 # Master stacked widget to switch between Pipeline, Datasets and Manage views 433 self.content_stack = QStackedWidget() 434 main_layout.addWidget(self.content_stack, stretch=1) 435 436 # Pipeline view: container with menu and animated stacked widget 437 self.pipeline_container = PipelineContainer(self, config=self.pipeline_config) 438 self.content_stack.addWidget(self.pipeline_container) 439 440 # Datasets view: dataset management page 441 self.datasets_page = DatasetsPage(self) 442 self.content_stack.addWidget(self.datasets_page) 443 444 # Manage view: categorical list widget 445 self.manage_widget = ManageAlignersWidget(self) 446 self.content_stack.addWidget(self.manage_widget) 447 448 # Start with Pipeline view 449 self.content_stack.setCurrentIndex(0) 450 451 # Set initial active tab style 452 self.update_active_tab_style("pipeline") 453 454 # Subtle status-bar entry point for the log viewer 455 self._init_log_status_entry()
def
eventFilter(self, obj, event):
496 def eventFilter(self, obj, event): # noqa: N802 (Qt API) 497 from PyQt6.QtCore import QEvent 498 499 if obj is self.centralWidget() and event.type() in ( 500 QEvent.Type.Resize, 501 QEvent.Type.Show, 502 ): 503 self._reposition_log_button() 504 return super().eventFilter(obj, event)
eventFilter(self, a0: QObject|None, a1: QEvent|None) -> bool
def
build_feedback_mailto_url( recipient: str, subject: str = 'VoxKit Feedback', body: str = 'Please share your feedback below.\n\nWhat were you trying to do?\n- \n\nWhat happened?\n- \n\nWhat did you expect instead?\n- \n\nAdditional context:\n- \n') -> str:
64def build_feedback_mailto_url( 65 recipient: str, 66 subject: str = FEEDBACK_SUBJECT, 67 body: str = FEEDBACK_BODY_TEMPLATE, 68) -> str: 69 encoded_subject = quote(subject, safe="") 70 encoded_body = quote(body, safe="") 71 return f"mailto:{recipient}?subject={encoded_subject}&body={encoded_body}"