diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 572c63f3..dc1bd130 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -33,7 +33,7 @@ jobs: conda activate sscanss conda install -c conda-forge libstdcxx-ng sudo apt-get update - sudo apt-get install xvfb libqt5x11extras5 libgl1-mesa-glx '^libxcb.*-dev' + sudo apt-get install xvfb libqt5x11extras5 libgl1 libglx-mesa0 '^libxcb.*-dev' xvfb-run --auto-servernum python make.py --build-all - name: Run unit-tests (Windows) if: runner.os == 'Windows' diff --git a/requirements.txt b/requirements.txt index fdfeebfc..fb7368a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ NLopt==2.7.1 numpy==1.23.5 gimpact==1.0.1 Pillow==9.2.0 -PyInstaller==6.7.0 +PyInstaller==6.11.1 PyOpenGL==3.1.6 PyQt6==6.3.1 PyQt6-Qt6==6.5.2 diff --git a/sscanss/app/dialogs/__init__.py b/sscanss/app/dialogs/__init__.py index 9ab00c66..64626294 100644 --- a/sscanss/app/dialogs/__init__.py +++ b/sscanss/app/dialogs/__init__.py @@ -1,5 +1,5 @@ -from .misc import (ProgressDialog, ProjectDialog, AlignmentErrorDialog, SimulationDialog, ScriptExportDialog, - PathLengthPlotter, AboutDialog, CalibrationErrorDialog, CurveEditor, InstrumentCoordinatesDialog) +from .misc import (ProgressDialog, ProjectWidget, AlignmentErrorDialog, SimulationDialog, ScriptExportDialog, + PathLengthPlotter, AboutWidget, CalibrationErrorDialog, CurveEditor, InstrumentCoordinatesDialog) from .preferences import Preferences from .insert import (InsertPrimitiveDialog, InsertPointDialog, InsertVectorDialog, PickPointDialog, AlignSample, VolumeLoader) diff --git a/sscanss/app/dialogs/misc.py b/sscanss/app/dialogs/misc.py index 4d14b311..cbabc893 100644 --- a/sscanss/app/dialogs/misc.py +++ b/sscanss/app/dialogs/misc.py @@ -16,7 +16,7 @@ from sscanss.app.widgets import AlignmentErrorModel, ErrorDetailModel, CenteredBoxProxy -class AboutDialog(QtWidgets.QDialog): +class AboutWidget(QtWidgets.QWidget): """Creates a UI that displays information about the software :param parent: main window instance @@ -25,14 +25,14 @@ class AboutDialog(QtWidgets.QDialog): def __init__(self, parent): super().__init__(parent) + self.setObjectName('FramelessDialog') self.main_layout = QtWidgets.QVBoxLayout() self.setLayout(self.main_layout) - self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Dialog) self.setMinimumSize(640, 560) self.main_layout.setContentsMargins(1, 1, 1, 1) header = ImageHeader() - header.close_button.clicked.connect(self.reject) + header.close_button.clicked.connect(self.close) self.main_layout.addWidget(header) layout = QtWidgets.QVBoxLayout() @@ -72,8 +72,16 @@ def __init__(self, parent): label.setWordWrap(True) layout.addWidget(label) + def paintEvent(self, event): + opt = QtWidgets.QStyleOption() + opt.initFrom(self) + p = QtGui.QPainter(self) + self.style().drawPrimitive(QtWidgets.QStyle.PrimitiveElement.PE_Widget, opt, p, self) + + super().paintEvent(event) + -class ProjectDialog(QtWidgets.QDialog): +class ProjectWidget(QtWidgets.QWidget): """Creates a UI for creating and loading projects :param parent: main window instance @@ -86,6 +94,7 @@ class ProjectDialog(QtWidgets.QDialog): def __init__(self, parent): super().__init__(parent) + self.setObjectName('FramelessDialog') self._busy = False self.parent = parent self.instruments = sorted(parent.presenter.model.instruments.keys()) @@ -94,13 +103,12 @@ def __init__(self, parent): self.main_layout = QtWidgets.QVBoxLayout() self.setLayout(self.main_layout) - self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.Dialog) self.setMinimumSize(640, 500) self.main_layout.setContentsMargins(1, 1, 1, 1) - header = ImageHeader() - header.close_button.clicked.connect(self.reject) - self.main_layout.addWidget(header) + self.header = ImageHeader() + self.header.close_button.clicked.connect(self.reject) + self.main_layout.addWidget(self.header) self.createTabWidgets() self.createNewProjectWidgets() @@ -117,7 +125,10 @@ def is_busy(self): @is_busy.setter def is_busy(self, value): self._busy = value - self.setModal(value) + self.parent.updateMenus(not value and self.parent.presenter.model.project_data is not None) + self.parent.docks.upper_dock.setDisabled(value) + self.parent.docks.bottom_dock.setDisabled(value) + self.header.close_button.setVisible(not value) self.loading_bar.setMaximum(0 if value else 1) def createTabWidgets(self): @@ -244,20 +255,24 @@ def onFailure(self, exception, args): self.parent.presenter.projectCreationError(exception, args) self.is_busy = False - def keyPressEvent(self, event): - """This ensures the user cannot close the dialog box with the Esc key""" - if not self.is_busy: - super().keyPressEvent(event) + def paintEvent(self, event): + opt = QtWidgets.QStyleOption() + opt.initFrom(self) + p = QtGui.QPainter(self) + self.style().drawPrimitive(QtWidgets.QStyle.PrimitiveElement.PE_Widget, opt, p, self) + + super().paintEvent(event) def accept(self): self.project_name_textbox.clear() self.is_busy = False - super().accept() + self.close() def reject(self): if self.parent.presenter.model.project_data is None: self.parent.instrument_label.setText("Most menu options are disabled until you create/open a project.") - super().reject() + self.is_busy = False + self.close() class ProgressDialog(QtWidgets.QDialog): diff --git a/sscanss/app/main.py b/sscanss/app/main.py index 9a17efd3..db246abc 100644 --- a/sscanss/app/main.py +++ b/sscanss/app/main.py @@ -30,7 +30,7 @@ def ui_execute(): msg = f'{filename} could not be opened because it has an unknown file type' QtCore.QTimer.singleShot(wait_time, lambda: window.showMessage(msg)) else: - QtCore.QTimer.singleShot(wait_time, window.showNewProjectDialog) + QtCore.QTimer.singleShot(wait_time, window.showNewProjectWidget) window.show() window.updater.check(True) diff --git a/sscanss/app/window/presenter.py b/sscanss/app/window/presenter.py index 4a28c15d..c9ccd8bb 100644 --- a/sscanss/app/window/presenter.py +++ b/sscanss/app/window/presenter.py @@ -96,7 +96,7 @@ def updateView(self): self.view.docks.closeAll() self.view.closeNonModalDialog() - self.view.updateMenus() + self.view.updateMenus(True) def projectCreationError(self, exception, args): """Handles errors from project creation or instrument change @@ -109,7 +109,7 @@ def projectCreationError(self, exception, args): self.view.docks.closeAll() if self.model.project_data is None or self.model.instrument is None: self.model.project_data = None - self.view.updateMenus() + self.view.updateMenus(False) self.view.clearUndoStack() else: toggle_action_in_group(self.model.instrument.name, self.view.change_instrument_action_group) diff --git a/sscanss/app/window/view.py b/sscanss/app/window/view.py index 82de8044..dcaa8611 100644 --- a/sscanss/app/window/view.py +++ b/sscanss/app/window/view.py @@ -10,8 +10,8 @@ from sscanss.__version import __version__, Version from sscanss.config import settings, DOCS_URL, UPDATE_URL, RELEASES_URL from sscanss.themes import ThemeManager, path_for, IconEngine -from sscanss.app.dialogs import (ProgressDialog, ProjectDialog, Preferences, AlignmentErrorDialog, ScriptExportDialog, - PathLengthPlotter, AboutDialog, CalibrationErrorDialog, InstrumentCoordinatesDialog, +from sscanss.app.dialogs import (ProgressDialog, ProjectWidget, Preferences, AlignmentErrorDialog, ScriptExportDialog, + PathLengthPlotter, AboutWidget, CalibrationErrorDialog, InstrumentCoordinatesDialog, CurveEditor, VolumeLoader) from sscanss.core.scene import Node, OpenGLRenderer, SceneManager from sscanss.core.util import (Primitives, Directions, TransformType, PointType, MessageType, Attributes, @@ -37,6 +37,69 @@ def __call__(self): return self.view.presenter.openProject(self.filename) +class OverlayContainer(QtWidgets.QWidget): + """A container that allow a widget to be overlaid over a background widget + + :param background: background widget + :type background: QWidget + :param parent: instance of main window + :type parent: MainWindow + """ + def __init__(self, background, parent): + super().__init__(parent) + + self.widget = None + self.main_layout = QtWidgets.QStackedLayout() + self.main_layout.setStackingMode(QtWidgets.QStackedLayout.StackingMode.StackAll) + self.setLayout(self.main_layout) + + self.main_layout.addWidget(background) + overlay_widget = QtWidgets.QWidget() + overlay_widget.setObjectName('overlay') + overlay_widget.setStyleSheet('#overlay {background: transparent;}') + + layout = QtWidgets.QHBoxLayout() + self.overlay_layout = QtWidgets.QVBoxLayout() + layout.addStretch(1) + layout.addLayout(self.overlay_layout) + layout.addStretch(1) + overlay_widget.setLayout(layout) + self.main_layout.addWidget(overlay_widget) + + def setOverlayWidget(self, widget): + """Sets and shows the overlay widget + + :param widget: widget to overlay over background + :type widget: QWidget + """ + self.closeOverlayWidget() + self.widget = widget + self.overlay_layout.addStretch(1) + self.overlay_layout.addWidget(widget) + self.overlay_layout.addStretch(1) + self.main_layout.setCurrentIndex(1) + + widget.installEventFilter(self) + + def eventFilter(self, _obj, event): + """Catch close event for overlay widget""" + if event.type() == QtCore.QEvent.Type.Close: + self.closeOverlayWidget() + return True + return False + + def closeOverlayWidget(self): + """Closes the overlay widget and show background""" + if self.widget is None: + return + + self.main_layout.setCurrentIndex(0) + for i in reversed(range(self.overlay_layout.count())): + self.overlay_layout.takeAt(i) + self.widget.hide() + self.widget = None + + class MainWindow(QtWidgets.QMainWindow): """Creates the main view for the sscanss app""" def __init__(self): @@ -54,9 +117,11 @@ def __init__(self): self.themes = ThemeManager(self) self.themes.theme_changed.connect(self.setStyleSheet) self.themes.theme_changed.connect(self.updateImages) + self.gl_widget = OpenGLRenderer(self) self.gl_widget.custom_error_handler = self.sceneSizeErrorHandler - self.setCentralWidget(self.gl_widget) + self.overlay = OverlayContainer(self.gl_widget, self) + self.setCentralWidget(self.overlay) self.docks = DockManager(self) self.scenes = SceneManager(self.presenter.model, self.gl_widget) @@ -76,7 +141,7 @@ def __init__(self): self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) self.readSettings() - self.updateMenus() + self.updateMenus(False) def createActions(self): """Creates the menu and toolbar actions """ @@ -84,13 +149,13 @@ def createActions(self): self.new_project_action.setStatusTip('Create a new project') self.new_project_action.setIcon(QtGui.QIcon(IconEngine('file.png'))) self.new_project_action.setShortcut(QtGui.QKeySequence.StandardKey.New) - self.new_project_action.triggered.connect(lambda: self.presenter.confirmSave(self.showNewProjectDialog)) + self.new_project_action.triggered.connect(lambda: self.presenter.confirmSave(self.showNewProjectWidget)) self.open_project_action = QtGui.QAction('&Open Project', self) self.open_project_action.setStatusTip('Open an existing project') self.open_project_action.setIcon(QtGui.QIcon(IconEngine('folder-open.png'))) self.open_project_action.setShortcut(QtGui.QKeySequence.StandardKey.Open) - self.open_project_action.triggered.connect(lambda: self.presenter.confirmSave(self.presenter.openProject())) + self.open_project_action.triggered.connect(lambda: self.presenter.confirmSave(self.presenter.openProject)) self.save_project_action = QtGui.QAction('&Save Project', self) self.save_project_action.setStatusTip('Save project') @@ -378,7 +443,7 @@ def createActions(self): self.show_about_action = QtGui.QAction(f'&About {MAIN_WINDOW_TITLE}', self) self.show_about_action.setStatusTip(f'About {MAIN_WINDOW_TITLE}') - self.show_about_action.triggered.connect(self.showAboutDialog) + self.show_about_action.triggered.connect(self.showAboutWidget) # ToolBar Actions self.rotate_sample_action = QtGui.QAction('Rotate Sample', self) @@ -422,10 +487,11 @@ def createActions(self): self.show_curve_editor_action.setIcon(QtGui.QIcon(IconEngine('curve.png'))) self.show_curve_editor_action.triggered.connect(self.showCurveEditor) - def showAboutDialog(self): + def showAboutWidget(self): """Display the About Dialog""" - self.createNonModalDialog(AboutDialog) - self.non_modal_dialog.show() + self.closeNonModalDialog() + about_dialog = AboutWidget(self) + self.overlay.setOverlayWidget(about_dialog) def updateImages(self): """Updates the images of the actions on the menu""" @@ -579,10 +645,8 @@ def createMenus(self): help_menu.addAction(self.check_update_action) help_menu.addAction(self.show_about_action) - def updateMenus(self): + def updateMenus(self, enable): """Disables the menus when a project is not created and enables menus when a project is created""" - enable = self.presenter.model.project_data is not None - self.save_project_action.setEnabled(enable) self.save_as_action.setEnabled(enable) for action in self.export_menu.actions(): @@ -706,6 +770,7 @@ def createStatusBar(self): sb.addPermanentWidget(self.size_label) def createNonModalDialog(self, dialog_type): + self.overlay.closeOverlayWidget() if not isinstance(self.non_modal_dialog, dialog_type): self.closeNonModalDialog() dialog = dialog_type(self) @@ -716,6 +781,7 @@ def createNonModalDialog(self, dialog_type): def closeNonModalDialog(self): if self.non_modal_dialog is not None: self.non_modal_dialog.close() + self.non_modal_dialog = None def clearUndoStack(self): """Clears the undo stack and ensures stack is cleaned even when stack is empty""" @@ -859,11 +925,12 @@ def __createChangeCollimatorAction(self, name, active, detector): return change_collimator_action - def showNewProjectDialog(self): - """Opens the new project dialog""" - self.createNonModalDialog(ProjectDialog) - self.non_modal_dialog.updateRecentProjects(self.recent_projects) - self.non_modal_dialog.show() + def showNewProjectWidget(self): + """Opens the new project widget""" + self.closeNonModalDialog() + project_dialog = ProjectWidget(self) + project_dialog.updateRecentProjects(self.recent_projects) + self.overlay.setOverlayWidget(project_dialog) def showPreferences(self, group=None): """Opens the preferences dialog""" diff --git a/sscanss/static/dark_theme.css b/sscanss/static/dark_theme.css index 8bc5ab12..03ad7468 100644 --- a/sscanss/static/dark_theme.css +++ b/sscanss/static/dark_theme.css @@ -29,6 +29,11 @@ QDialog background-color: rgb(52, 49, 49); } +#FramelessDialog{ + border: 1px solid rgb(70, 70, 70); + color: rgb(255, 255, 255); + background-color: rgb(52, 49, 49); +} QListWidget { @@ -601,7 +606,7 @@ QProgressBar::chunk width: 3px; margin: 1px; } -ProjectDialog QProgressBar +ProjectWidget QProgressBar { border: transparent; border-radius: 0px; diff --git a/sscanss/static/mac_style.css b/sscanss/static/mac_style.css index cbedb63e..d7b5a469 100644 --- a/sscanss/static/mac_style.css +++ b/sscanss/static/mac_style.css @@ -20,7 +20,11 @@ QDialog{ border: 1px solid #ddd; } - + + #FramelessDialog{ + border: 1px solid #ddd; + background-color: #eee; + } QToolBar { spacing: 10px; /* spacing between items in the tool bar */ padding: 5px; @@ -258,7 +262,7 @@ QComboBox { image: url(@Path/splitter.png); } - ProjectDialog QProgressBar { + ProjectWidget QProgressBar { border: transparent; border-radius: 0px; background-color: transparent; diff --git a/sscanss/static/style.css b/sscanss/static/style.css index 4d4b5bcd..54c219cd 100644 --- a/sscanss/static/style.css +++ b/sscanss/static/style.css @@ -4,6 +4,12 @@ color: #333; } +#FramelessDialog{ + border: 1px solid #ddd; + background-color: #eee; +} + + #ColourPalette { qproperty-html_anchor: rgb(0, 150, 255); qproperty-scene_anchor: rgb(0, 0, 200); @@ -162,7 +168,7 @@ PickPointDialog QSplitter::handle { image: url(@Path/splitter.png); } -ProjectDialog QProgressBar { +ProjectWidget QProgressBar { border: transparent; border-radius: 0px; background-color: transparent; diff --git a/tests/test_ui.py b/tests/test_ui.py index 65f3a80f..67ea7deb 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -11,9 +11,9 @@ from OpenGL.plugins import FormatHandler from sscanss.app.dialogs import (InsertPrimitiveDialog, TransformDialog, InsertPointDialog, PathLengthPlotter, InsertVectorDialog, VectorManager, PickPointDialog, JawControl, PositionerControl, - DetectorControl, PointManager, SimulationDialog, ScriptExportDialog, ProjectDialog, + DetectorControl, PointManager, SimulationDialog, ScriptExportDialog, ProjectWidget, Preferences, CalibrationErrorDialog, AlignmentErrorDialog, SampleProperties, - AboutDialog) + AboutWidget, InstrumentCoordinatesDialog) from sscanss.app.window.view import MainWindow import sscanss.config as config from sscanss.core.instrument import Simulation @@ -108,7 +108,11 @@ def testMainView(self, file_dialog_mock): self.openOtherWindows() self.createProject() + self.assertEqual(self.window.undo_stack.count(), 0) self.addSample() + self.assertEqual(self.window.undo_stack.count(), 2) + self.window.clearUndoStack() + self.assertEqual(self.window.undo_stack.count(), 0) self.assertFalse(self.window.gl_widget.show_bounding_box) QTest.mouseClick(self.toolbar.widgetForAction(self.window.show_bounding_box_action), Qt.MouseButton.LeftButton) self.assertTrue(self.window.gl_widget.show_bounding_box) @@ -163,10 +167,12 @@ def toggleThemes(self): self.assertEqual(dark_theme, dark_expected) def createProject(self): - self.window.showNewProjectDialog() + self.window.showNewProjectWidget() # Test project dialog validation - project_dialog = self.window.findChild(ProjectDialog) + self.assertIsInstance(self.window.overlay.widget, ProjectWidget) + project_dialog = self.window.overlay.widget + QTest.qWait(WAIT_TIME // 200) self.assertTrue(project_dialog.isVisible()) self.assertEqual(project_dialog.validator_textbox.text(), "") QTest.mouseClick(project_dialog.create_project_button, Qt.MouseButton.LeftButton) @@ -790,7 +796,7 @@ def runSimulation(self): QTest.qWait(WAIT_TIME // 5) self.assertTrue(self.model.simulation.isRunning()) - QTest.qWait(WAIT_TIME * 5) + QTest.qWait(WAIT_TIME * 3) self.assertFalse(self.model.simulation.isRunning()) self.assertEqual(len(self.model.simulation.results), 6) @@ -831,13 +837,14 @@ def runSimulation(self): def openOtherWindows(self): self.window.show_about_action.trigger() - self.assertIsInstance(self.window.non_modal_dialog, AboutDialog) - about_dialog = self.window.non_modal_dialog + self.assertIsInstance(self.window.overlay.widget, AboutWidget) + QTest.qWait(WAIT_TIME // 1000) + about_dialog = self.window.overlay.widget self.assertTrue(about_dialog.isVisible()) header = about_dialog.findChild(ImageHeader) QTest.mouseClick(header.close_button, Qt.MouseButton.LeftButton, delay=100) + QTest.qWait(WAIT_TIME // 1000) self.assertFalse(about_dialog.isVisible()) - self.assertEqual(about_dialog.result(), about_dialog.DialogCode.Rejected) # Test the Recent project menu self.window.recent_projects = [] @@ -858,17 +865,32 @@ def openOtherWindows(self): self.window.populateRecentMenu() self.assertEqual(len(self.window.recent_menu.actions()), 8) - self.window.undo_stack.setClean() self.window.show_about_action.trigger() - self.assertIsInstance(self.window.non_modal_dialog, AboutDialog) + self.assertIsInstance(self.window.overlay.widget, AboutWidget) self.window.new_project_action.trigger() - self.assertIsInstance(self.window.non_modal_dialog, ProjectDialog) - project_dialog = self.window.non_modal_dialog + QTest.qWait(WAIT_TIME // 1000) + self.assertIsInstance(self.window.overlay.widget, ProjectWidget) + project_dialog = self.window.overlay.widget # update recent project in the show method self.assertEqual(project_dialog.list_widget.count(), 6) - QTest.keyClick(project_dialog, Qt.Key.Key_Escape) + header = about_dialog.findChild(ImageHeader) + QTest.mouseClick(header.close_button, Qt.MouseButton.LeftButton, delay=100) + QTest.qWait(WAIT_TIME // 1000) self.assertFalse(project_dialog.isVisible()) + self.window.show_about_action.trigger() + QTest.qWait(WAIT_TIME // 1000) + self.assertTrue(self.window.overlay.widget.isVisible()) + self.assertIsNone(self.window.non_modal_dialog) + self.window.showInstrumentCoordinates() + QTest.qWait(WAIT_TIME // 1000) + self.assertIsNone(self.window.overlay.widget) + self.assertIsInstance(self.window.non_modal_dialog, InstrumentCoordinatesDialog) + self.window.show_about_action.trigger() + QTest.qWait(WAIT_TIME // 1000) + self.assertTrue(self.window.overlay.widget.isVisible()) + self.assertIsNone(self.window.non_modal_dialog) + self.window.undo_view_action.trigger() self.assertTrue(self.window.undo_view.isVisible()) self.window.undo_view.close()