diff --git a/pyproject.toml b/pyproject.toml index cd0539ba..b101b201 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ ptychozoon = ["ptychozoon"] [tool.uv.sources] #ptychi = { path = "../pty-chi", editable = true } #ptychopinn = { path = "../PtychoPINN", editable = true } -ptychopinn = { git = "https://github.com/hoidn/PtychoPINN", branch = "main" } +#ptychopinn = { git = "https://github.com/hoidn/PtychoPINN", branch = "main" } #[[tool.uv.index]] #name = "pytorch-cu132" diff --git a/src/ptychodus/__main__.py b/src/ptychodus/__main__.py index a25858ac..28c9104c 100644 --- a/src/ptychodus/__main__.py +++ b/src/ptychodus/__main__.py @@ -101,7 +101,7 @@ def main() -> int: from ptychodus.view import ViewCore - view = ViewCore() + view = ViewCore(is_developer_mode_enabled=model.is_developer_mode_enabled) from ptychodus.controller import ControllerCore diff --git a/src/ptychodus/api/plugins.py b/src/ptychodus/api/plugins.py index 9b25c9df..929ce65b 100644 --- a/src/ptychodus/api/plugins.py +++ b/src/ptychodus/api/plugins.py @@ -118,6 +118,8 @@ def register_plugin(self, strategy: T, *, display_name: str, simple_name: str = def get_current_plugin(self) -> Plugin[T]: """Return the currently selected plugin.""" + if not self._registered_plugins: + raise LookupError('No plugins registered') return self._registered_plugins[self._current_index] def set_current_plugin(self, name: str) -> None: diff --git a/src/ptychodus/controller/core.py b/src/ptychodus/controller/core.py index 3fc78e73..f395ca26 100644 --- a/src/ptychodus/controller/core.py +++ b/src/ptychodus/controller/core.py @@ -92,15 +92,16 @@ def __init__( view.product_view, self._file_dialog_factory, ) - self._product_visualization_controller = ProductVisualizationController( - model.analysis_core.residual_analyzer, - model.analysis_core.residual_real_space_visualization_engine, - model.analysis_core.residual_reciprocal_space_visualization_engine, - self._product_controller, - view.product_visualization_view, - self._status_bar, - self._file_dialog_factory, - ) + if is_developer_mode_enabled: + self._product_visualization_controller = ProductVisualizationController( + model.analysis_core.residual_analyzer, + model.analysis_core.residual_real_space_visualization_engine, + model.analysis_core.residual_reciprocal_space_visualization_engine, + self._product_controller, + view.product_visualization_view, + self._status_bar, + self._file_dialog_factory, + ) self._probe_positions_controller = ProbePositionsController( model.product_core.probe_positions_repository, model.product_core.probe_positions_api, @@ -209,7 +210,7 @@ def __init__( view.genesis_action: model.genesis_core.is_supported, } - self._swap_central_widgets(view.patterns_action) + self._swap_central_widgets(view.patterns_action, animated=False) view.patterns_action.setChecked(True) view.navigation.action_group.triggered.connect( lambda action: self._swap_central_widgets(action) @@ -222,19 +223,20 @@ def show_main_window(self, window_title: str) -> None: self.view.setWindowTitle(window_title) self.view.show() - def _swap_central_widgets(self, action: QAction | None) -> None: + def _swap_central_widgets(self, action: QAction | None, *, animated: bool = True) -> None: if action is None: raise ValueError('QAction is None!') self.view.navigation.set_current_index(action.data()) - self._update_subview_visibility(action) + self._update_subview_visibility(action, animated=animated) - def _update_subview_visibility(self, action: QAction) -> None: + def _update_subview_visibility(self, action: QAction, *, animated: bool = True) -> None: for group in self.view.navigation.subview_groups: expanded = action is group.parent_action or action in group.child_actions for child in group.child_actions: allowed = self._action_permitted.get(child, True) - child.setVisible(expanded and allowed) + group.container.set_child_button_visible(child, allowed) + group.container.set_expanded(expanded, animated=animated) group.top_separator.setVisible(expanded) group.bottom_separator.setVisible(expanded) diff --git a/src/ptychodus/model/genesis/core.py b/src/ptychodus/model/genesis/core.py index 3d313650..7f367264 100644 --- a/src/ptychodus/model/genesis/core.py +++ b/src/ptychodus/model/genesis/core.py @@ -102,38 +102,59 @@ def supported_transfer_clients(self) -> Sequence[str]: return [plugin.display_name for plugin in self._transfer_client_chooser] def refresh_projects(self) -> None: + if not self._facility_chooser: + logger.debug('refresh_projects: no facility adapter registered; skipping') + return facility_adapter = self._facility_chooser.get_current_plugin().strategy facility_adapter.refresh_projects() def supported_projects(self) -> Sequence[str]: + if not self._facility_chooser: + return [] facility_adapter = self._facility_chooser.get_current_plugin().strategy return facility_adapter.project_names() def map_project_name_to_id(self, name: str) -> str: + if not self._facility_chooser: + return name facility_adapter = self._facility_chooser.get_current_plugin().strategy return facility_adapter.map_project_name_to_id(name) def map_project_id_to_name(self, project_id: str) -> str: + if not self._facility_chooser: + return project_id facility_adapter = self._facility_chooser.get_current_plugin().strategy return facility_adapter.map_project_id_to_name(project_id) def refresh_compute_resources(self) -> None: + if not self._facility_chooser: + logger.debug('refresh_compute_resources: no facility adapter registered; skipping') + return facility_adapter = self._facility_chooser.get_current_plugin().strategy facility_adapter.refresh_compute_resources() def supported_compute_resources(self) -> Sequence[str]: + if not self._facility_chooser: + return [] facility_adapter = self._facility_chooser.get_current_plugin().strategy return facility_adapter.compute_resource_names() def map_compute_resource_name_to_id(self, name: str) -> str: + if not self._facility_chooser: + return name facility_adapter = self._facility_chooser.get_current_plugin().strategy return facility_adapter.map_compute_resource_name_to_id(name) def map_compute_resource_id_to_name(self, resource_id: str) -> str: + if not self._facility_chooser: + return resource_id facility_adapter = self._facility_chooser.get_current_plugin().strategy return facility_adapter.map_compute_resource_id_to_name(resource_id) def apply_facility_defaults(self) -> None: + if not self._facility_chooser: + logger.debug('apply_facility_defaults: no facility adapter registered; skipping') + return adapter = self._facility_chooser.get_current_plugin().strategy globus_collection = adapter.get_default_globus_collection() @@ -163,10 +184,13 @@ def __init__( for name, provider in create_globus_transfer_providers().items(): self._transfer_client_chooser.register_plugin(provider, display_name=name) - self._facility_chooser.synchronize_with_parameter(self.settings.facility) - self._transfer_client_chooser.synchronize_with_parameter( - self.settings.globus_transfer_provider - ) + if self._facility_chooser: + self._facility_chooser.synchronize_with_parameter(self.settings.facility) + + if self._transfer_client_chooser: + self._transfer_client_chooser.synchronize_with_parameter( + self.settings.globus_transfer_provider + ) status_q: queue.Queue[GenesisStatus] = queue.Queue() self.status_repository = GenesisStatusRepository(status_q) diff --git a/src/ptychodus/view/core.py b/src/ptychodus/view/core.py index be1da4a5..e707dd36 100644 --- a/src/ptychodus/view/core.py +++ b/src/ptychodus/view/core.py @@ -2,7 +2,14 @@ from dataclasses import dataclass import logging -from PyQt5.QtCore import PYQT_VERSION_STR, QSize, QT_VERSION_STR, Qt +from PyQt5.QtCore import ( + PYQT_VERSION_STR, + QEasingCurve, + QPropertyAnimation, + QSize, + QT_VERSION_STR, + Qt, +) from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import ( QAction, @@ -16,6 +23,7 @@ QTableView, QToolBar, QToolButton, + QVBoxLayout, QWidget, ) @@ -32,20 +40,81 @@ logger = logging.getLogger(__name__) -_SUBVIEW_BUTTON_STYLE = ( - 'QToolButton { padding-left: 12px; background-color: palette(mid); }' - 'QToolButton:hover { background-color: palette(midlight); }' - 'QToolButton:checked {' +_SUBVIEW_GROUP_STYLE = ( + '_SubviewGroupContainer { background-color: palette(mid); }' + '_SubviewGroupContainer QToolButton { background: transparent; border: none; }' + '_SubviewGroupContainer QToolButton:hover { background-color: palette(midlight); }' + '_SubviewGroupContainer QToolButton:checked {' ' background-color: palette(highlight);' ' color: palette(highlighted-text);' '}' ) +_EXPAND_COLLAPSE_DURATION_MS = 180 + + +class _SubviewGroupContainer(QWidget): + def __init__( + self, + child_actions: tuple[QAction, ...], + *, + icon_size: QSize, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setStyleSheet(_SUBVIEW_GROUP_STYLE) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._buttons: dict[QAction, QToolButton] = {} + for action in child_actions: + btn = QToolButton(self) + btn.setDefaultAction(action) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + btn.setIconSize(icon_size) + layout.addWidget(btn) + self._buttons[action] = btn + + layout.activate() + self._cached_natural_height = layout.sizeHint().height() + + self.setMaximumHeight(0) + self._expanded = False + + self._animation = QPropertyAnimation(self, b'maximumHeight', self) + self._animation.setDuration(_EXPAND_COLLAPSE_DURATION_MS) + self._animation.setEasingCurve(QEasingCurve.Type.InOutQuad) + + def child_button(self, action: QAction) -> QToolButton | None: + return self._buttons.get(action) + + def set_child_button_visible(self, action: QAction, visible: bool) -> None: + btn = self._buttons.get(action) + if btn is not None: + btn.setVisible(visible) + + def set_expanded(self, expanded: bool, *, animated: bool = True) -> None: + running = self._animation.state() == QPropertyAnimation.State.Running + if expanded == self._expanded and not running: + return + self._expanded = expanded + self._animation.stop() + target = self._cached_natural_height if expanded else 0 + if not animated: + self.setMaximumHeight(target) + return + self._animation.setStartValue(self.maximumHeight()) + self._animation.setEndValue(target) + self._animation.start() + @dataclass(frozen=True) class NavigationSubviewGroup: parent_action: QAction child_actions: tuple[QAction, ...] + container: _SubviewGroupContainer top_separator: QAction bottom_separator: QAction @@ -56,6 +125,7 @@ def __init__(self) -> None: self.action_group = QActionGroup(self.tool_bar) self.left_stack = QStackedWidget() self.right_stack = QStackedWidget() + self.top_level_actions: list[QAction] = [] self.subview_groups: list[NavigationSubviewGroup] = [] def add_panel(self, icon: QIcon, label: str, *, left: QWidget, right: QWidget) -> QAction: @@ -67,6 +137,7 @@ def add_panel(self, icon: QIcon, label: str, *, left: QWidget, right: QWidget) - self.action_group.addAction(action) self.left_stack.addWidget(left) self.right_stack.addWidget(right) + self.top_level_actions.append(action) return action def add_subview_group( @@ -74,14 +145,23 @@ def add_subview_group( parent_action: QAction, child_actions: tuple[QAction, ...], *, - top_separator_before: QAction, - bottom_separator_before: QAction, + insert_before: QAction, + child_icon_size: QSize, ) -> NavigationSubviewGroup: + for child in child_actions: + self.tool_bar.removeAction(child) + top_separator = self.tool_bar.insertSeparator(insert_before) + container = _SubviewGroupContainer(child_actions, icon_size=child_icon_size) + self.tool_bar.insertWidget(insert_before, container) + bottom_separator = self.tool_bar.insertSeparator(insert_before) + top_separator.setVisible(False) + bottom_separator.setVisible(False) group = NavigationSubviewGroup( parent_action=parent_action, child_actions=child_actions, - top_separator=self.tool_bar.insertSeparator(top_separator_before), - bottom_separator=self.tool_bar.insertSeparator(bottom_separator_before), + container=container, + top_separator=top_separator, + bottom_separator=bottom_separator, ) self.subview_groups.append(group) return group @@ -90,9 +170,38 @@ def set_current_index(self, index: int) -> None: self.left_stack.setCurrentIndex(index) self.right_stack.setCurrentIndex(index) + def normalize_button_widths(self) -> None: + top_level_buttons: list[QToolButton] = [] + for action in self.top_level_actions: + widget = self.tool_bar.widgetForAction(action) + if isinstance(widget, QToolButton): + top_level_buttons.append(widget) + + child_buttons: list[QToolButton] = [] + for group in self.subview_groups: + for action in group.child_actions: + btn = group.container.child_button(action) + if btn is not None: + child_buttons.append(btn) + + all_buttons = top_level_buttons + child_buttons + if not all_buttons: + return + + target_width = max(btn.sizeHint().width() for btn in all_buttons) + for btn in all_buttons: + btn.setFixedWidth(target_width) + for group in self.subview_groups: + group.container.setFixedWidth(target_width) + class ViewCore(QMainWindow): - def __init__(self, parent: QWidget | None = None) -> None: + def __init__( + self, + parent: QWidget | None = None, + *, + is_developer_mode_enabled: bool = False, + ) -> None: super().__init__(parent) logger.info(f'PyQt {PYQT_VERSION_STR}') @@ -121,12 +230,16 @@ def __init__(self, parent: QWidget | None = None) -> None: ) self.product_view = ProductView() - self.product_visualization_view = ProductVisualizationView() + if is_developer_mode_enabled: + self.product_visualization_view = ProductVisualizationView() + product_right: QWidget = self.product_visualization_view + else: + product_right = QWidget() self.product_action = self.navigation.add_panel( QIcon(':/icons/products'), 'Products', left=self.product_view, - right=self.product_visualization_view, + right=product_right, ) self.probe_positions_view = RepositoryTableView() @@ -201,19 +314,6 @@ def __init__(self, parent: QWidget | None = None) -> None: right=self.agent_chat_view, ) - self.navigation.add_subview_group( - parent_action=self.product_action, - child_actions=(self.positions_action, self.probe_action, self.object_action), - top_separator_before=self.positions_action, - bottom_separator_before=self.processing_action, - ) - self.navigation.add_subview_group( - parent_action=self.processing_action, - child_actions=(self.globus_action, self.genesis_action, self.automation_action), - top_separator_before=self.globus_action, - bottom_separator_before=self.agent_action, - ) - ##### self.setWindowIcon(QIcon(':/icons/ptychodus')) @@ -223,12 +323,20 @@ def __init__(self, parent: QWidget | None = None) -> None: self.navigation.tool_bar.setIconSize(QSize(32, 32)) self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, self.navigation.tool_bar) - for group in self.navigation.subview_groups: - for action in group.child_actions: - btn = self.navigation.tool_bar.widgetForAction(action) - if isinstance(btn, QToolButton): - btn.setIconSize(QSize(24, 24)) - btn.setStyleSheet(_SUBVIEW_BUTTON_STYLE) + self.navigation.add_subview_group( + parent_action=self.product_action, + child_actions=(self.positions_action, self.probe_action, self.object_action), + insert_before=self.processing_action, + child_icon_size=QSize(24, 24), + ) + self.navigation.add_subview_group( + parent_action=self.processing_action, + child_actions=(self.globus_action, self.genesis_action, self.automation_action), + insert_before=self.agent_action, + child_icon_size=QSize(24, 24), + ) + + self.navigation.normalize_button_widths() self.navigation.left_stack.setSizePolicy( QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum diff --git a/tests/test_plugins.py b/tests/test_plugins.py new file mode 100644 index 00000000..e2375f68 --- /dev/null +++ b/tests/test_plugins.py @@ -0,0 +1,37 @@ +"""Unit tests for ptychodus.api.plugins.PluginChooser.""" + +from __future__ import annotations + +import pytest + +from ptychodus.api.plugins import PluginChooser + + +def test_get_current_plugin_empty_raises_lookup_error() -> None: + chooser: PluginChooser[str] = PluginChooser() + + with pytest.raises(LookupError): + chooser.get_current_plugin() + + +def test_get_current_plugin_returns_registered() -> None: + chooser: PluginChooser[str] = PluginChooser() + chooser.register_plugin('strategy-a', display_name='Strategy A') + + plugin = chooser.get_current_plugin() + + assert plugin.strategy == 'strategy-a' + assert plugin.display_name == 'Strategy A' + + +def test_empty_chooser_is_falsy() -> None: + chooser: PluginChooser[str] = PluginChooser() + + assert not chooser + + +def test_populated_chooser_is_truthy() -> None: + chooser: PluginChooser[str] = PluginChooser() + chooser.register_plugin('s', display_name='S') + + assert chooser