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.

app_config
pipeline_config
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_help(self):
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)
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}"