diff --git a/pydm/display.py b/pydm/display.py index af43edbba..bc57c5e8c 100644 --- a/pydm/display.py +++ b/pydm/display.py @@ -14,7 +14,9 @@ import re import six -from qtpy.QtWidgets import QApplication, QWidget +import xml.etree.ElementTree as ET + +from qtpy.QtWidgets import QApplication, QMainWindow, QWidget from .help_files import HelpWindow from .utilities import import_module_by_filename, is_pydm_app, macro, ACTIVE_QT_WRAPPER, QtWrapperTypes @@ -186,6 +188,26 @@ def _load_compiled_ui_into_display( display.ui = display +def _get_ui_root_widget_class(uifile): + """Return the class name of the root widget defined in a .ui file. + + Parameters + ---------- + uifile : str + Path to the .ui file. + + Returns + ------- + str + The root widget class name (e.g. ``"QWidget"``, ``"QMainWindow"``). + """ + tree = ET.parse(uifile) + widget_elem = tree.getroot().find("widget") + if widget_elem is not None: + return widget_elem.get("class", "QWidget") + return "QWidget" + + def load_ui_file(uifile, macros=None, args=None): """ Load a .ui file, perform macro substitution, then return the resulting QWidget. @@ -207,7 +229,11 @@ def load_ui_file(uifile, macros=None, args=None): QWidget """ - display = Display(macros=macros) + root_class = _get_ui_root_widget_class(uifile) + if root_class == "QMainWindow": + display = MainWindowDisplay(macros=macros) + else: + display = Display(macros=macros) display.load_ui_from_file(uifile, macros) return display @@ -329,9 +355,26 @@ def load_py_file(pyfile, args=None, macros=None): } -class Display(QWidget): - def __init__(self, parent=None, args=None, macros=None, ui_filename=None): - super().__init__(parent) +class DisplayBase: + """Shared implementation for display-like widgets. + + Provides navigation, macro handling, file loading, and stylesheet + support. Mixed into both :class:`Display` (QWidget-based) and + :class:`MainWindowDisplay` (QMainWindow-based). + """ + + def _init_display(self, args=None, macros=None, ui_filename=None): + """Initialize display state. Must be called from subclass ``__init__``. + + Parameters + ---------- + args : list, optional + Command-line arguments forwarded to the display. + macros : dict, optional + Macro substitutions. + ui_filename : str, optional + Filename of the .ui file to load. + """ self.ui = None self.help_window = None self._ui_filename = ui_filename @@ -341,8 +384,6 @@ def __init__(self, parent=None, args=None, macros=None, ui_filename=None): self._previous_display = None self._next_display = None self._local_style = "" - if ui_filename or self.ui_filename(): - self.load_ui(macros=macros) def loaded_file(self): return self._loaded_file @@ -483,3 +524,32 @@ def setStyleSheet(self, new_stylesheet): self._local_style = f.read() logger.debug("Setting stylesheet to: %s", self._local_style) super().setStyleSheet(self._local_style) + + +class Display(DisplayBase, QWidget): + def __init__(self, parent=None, args=None, macros=None, ui_filename=None): + super().__init__(parent) + self._init_display(args=args, macros=macros, ui_filename=ui_filename) + if ui_filename or self.ui_filename(): + self.load_ui(macros=macros) + + +class MainWindowDisplay(DisplayBase, QMainWindow): + """Display backed by QMainWindow for .ui files that use QMainWindow as + their root widget. + + Inherits the full display interface from :class:`DisplayBase` so it + passes ``isinstance(widget, Display)`` checks used throughout pydm for + navigation, menu items, macro propagation, and help support. + + Parameters + ---------- + parent : QWidget, optional + The parent widget. + macros : dict, optional + Macro substitutions for the display. + """ + + def __init__(self, parent=None, macros=None): + super().__init__(parent) + self._init_display(macros=macros) diff --git a/pydm/main_window.py b/pydm/main_window.py index 77abd2c40..159fda9bd 100644 --- a/pydm/main_window.py +++ b/pydm/main_window.py @@ -11,7 +11,7 @@ close_widget_connections, ) from .pydm_ui import Ui_MainWindow -from .display import Display, ScreenTarget, load_file, clear_compiled_ui_file_cache +from .display import Display, DisplayBase, ScreenTarget, load_file, clear_compiled_ui_file_cache from .connection_inspector import ConnectionInspector from .about_pydm import AboutWindow from .show_macros import MacroWindow @@ -255,7 +255,7 @@ def enable_disable_navigation(self): self.ui.actionForward.setDisabled(True) return - if not isinstance(w, Display): + if not isinstance(w, DisplayBase): # We can't do much if it is not a Display and we don't have the # previous_display and next_display properties since we don't # have the navigation stack set. @@ -327,7 +327,7 @@ def get_files_in_display(self): if extension == ".ui": return self.current_file(), None else: - central_widget = self.centralWidget() if isinstance(self.centralWidget(), Display) else None + central_widget = self.centralWidget() if isinstance(self.centralWidget(), DisplayBase) else None if central_widget is not None: ui_file = central_widget.ui_filepath() return ui_file, self.current_file() @@ -571,7 +571,7 @@ def show_macro_window(self): def add_menu_items(self): # create the custom menu with user given items - if not isinstance(self.display_widget(), Display): + if not isinstance(self.display_widget(), DisplayBase): return # Only provide the view help menu option if an associated help file has been loaded diff --git a/pydm/tests/test_data/mainwindow_test.ui b/pydm/tests/test_data/mainwindow_test.ui new file mode 100644 index 000000000..f92786a67 --- /dev/null +++ b/pydm/tests/test_data/mainwindow_test.ui @@ -0,0 +1,23 @@ + + + MainWindow + + + + 0 + 0 + 400 + 300 + + + + + + Test Label + + + + + + + diff --git a/pydm/tests/test_mainwindow_display.py b/pydm/tests/test_mainwindow_display.py new file mode 100644 index 000000000..90e65199c --- /dev/null +++ b/pydm/tests/test_mainwindow_display.py @@ -0,0 +1,67 @@ +import os +import pytest + +from pydm.display import Display, MainWindowDisplay, load_ui_file, _get_ui_root_widget_class +from qtpy.QtWidgets import QMainWindow, QWidget + +TEST_DATA = os.path.join(os.path.dirname(__file__), "test_data") +MAINWINDOW_UI = os.path.join(TEST_DATA, "mainwindow_test.ui") + + +def test_get_ui_root_widget_class_mainwindow(): + """Detect QMainWindow as root widget class in a .ui file.""" + assert _get_ui_root_widget_class(MAINWINDOW_UI) == "QMainWindow" + + +def test_load_mainwindow_ui_returns_mainwindow_display(qtbot): + """Loading a QMainWindow .ui file produces a MainWindowDisplay instance. + + Parameters + ---------- + qtbot : fixture + pytest-qt fixture for widget management. + """ + display = load_ui_file(MAINWINDOW_UI) + qtbot.addWidget(display) + assert isinstance(display, MainWindowDisplay) + assert isinstance(display, QMainWindow) + + +def test_load_mainwindow_ui_does_not_crash(qtbot): + """QMainWindow .ui files should load without AttributeError. + + Parameters + ---------- + qtbot : fixture + pytest-qt fixture for widget management. + """ + display = load_ui_file(MAINWINDOW_UI) + qtbot.addWidget(display) + assert hasattr(display, "setCentralWidget") + assert display.centralWidget() is not None + + +def test_mainwindow_display_has_full_display_interface(qtbot): + """MainWindowDisplay should have all Display methods so isinstance checks + and navigation/menu/macro features work correctly. + + Parameters + ---------- + qtbot : fixture + pytest-qt fixture for widget management. + """ + display = load_ui_file(MAINWINDOW_UI) + qtbot.addWidget(display) + + assert hasattr(display, "args") + assert hasattr(display, "macros") + assert hasattr(display, "loaded_file") + assert hasattr(display, "menu_items") + assert hasattr(display, "file_menu_items") + assert hasattr(display, "show_help") + assert hasattr(display, "navigate_back") + assert hasattr(display, "navigate_forward") + assert hasattr(display, "load_ui_from_file") + assert hasattr(display, "load_help_file") + assert hasattr(display, "previous_display") + assert hasattr(display, "next_display") diff --git a/pydm/widgets/base.py b/pydm/widgets/base.py index 70dea5ea9..b10d77b43 100644 --- a/pydm/widgets/base.py +++ b/pydm/widgets/base.py @@ -14,7 +14,7 @@ from .channel import PyDMChannel from pydm import data_plugins, tools, config from pydm.utilities import is_qt_designer, remove_protocol -from pydm.display import Display +from pydm.display import Display, DisplayBase from pydm.utilities import ACTIVE_QT_WRAPPER, QtWrapperTypes if ACTIVE_QT_WRAPPER == QtWrapperTypes.PYSIDE6: @@ -368,7 +368,7 @@ def setRules(self, new_rules) -> None: def find_parent_display(self): widget = self.parent() while widget is not None: - if isinstance(widget, Display): + if isinstance(widget, DisplayBase): return widget widget = widget.parent() return None