diff --git a/docs/source/index.rst b/docs/source/index.rst index 3c10c0e28..99d6fd059 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -44,6 +44,7 @@ Related Projects python_methods.rst templates.rst release_notes.rst + upcoming_changes.rst .. toctree:: :maxdepth: 3 diff --git a/docs/source/templates.rst b/docs/source/templates.rst index 4660cb774..60b924557 100644 --- a/docs/source/templates.rst +++ b/docs/source/templates.rst @@ -75,3 +75,20 @@ custom templates: point is expecting to find a ``str``, ``pathlib.Path``, or ``list`` of such objects at your entry point. One such example of how to do this can be found `here `_ + + +For top-level devices (e.g., ``at2l0``), the template load priority is as +follows: + +- Happi-defined values (``"detailed_screen"``, ``embedded_screen"``, + ``"engineering_screen"``) +- Device-specific screens, if available (named as ``ClassNameHere.detailed.ui``) +- The detailed tree, if the device has sub-devices +- The default templates + +For nested displays in a device tree, sub-device (e.g., ``at2l0.blade_01``) +template load priority is as follows: + +- Device-specific screens, if available (named as ``ClassNameHere.embedded.ui``) +- The detailed tree, if the device has sub-devices +- The default templates diff --git a/docs/source/upcoming_release_notes/563-row_positioner.rst b/docs/source/upcoming_release_notes/563-row_positioner.rst new file mode 100644 index 000000000..ee449b21f --- /dev/null +++ b/docs/source/upcoming_release_notes/563-row_positioner.rst @@ -0,0 +1,50 @@ +563 row-positioner +################## + +API Changes +----------- +- ``TyphosNoteEdit`` now supports ``.add_device()`` like other typhos widgets. + This is alongside its original ``setup_data`` API. +- ``TyphosDeviceDisplay`` composite heuristics have been removed in favor of + simpler methods, described in the features section. + +Features +-------- +- ``TyphosNoteEdit`` is now a ``TyphosBase`` object and is accessible in the Qt + designer. +- Added new designable widget ``TyphosPositionerRowWidget``. This compact + positioner widget makes dense motor-heavy screens much more space efficient. +- The layout method for ``TyphosDeviceDisplay`` has changed. For large device trees, + it now favors showing the compact "embedded" screens over detailed screens. The order + of priority is now as follows: +- For top-level devices (e.g., ``at2l0``), the template load priority is as follows: + + * Happi-defined values (``"detailed_screen"``, ``embedded_screen"``, ``"engineering_screen"``) + * Device-specific screens, if available (named as ``ClassNameHere.detailed.ui``) + * The detailed tree, if the device has sub-devices + * The default templates + +- For nested displays in a device tree, sub-device (e.g., ``at2l0.blade_01``) + template load priority is as follows: + + * Device-specific screens, if available (named as ``ClassNameHere.embedded.ui``) + * The detailed tree, if the device has sub-devices + * The default templates (``embedded_screen.ui``) + +Bugfixes +-------- +- For devices which do not require keyword arguments to instantiate, the typhos + CLI will no longer require an empty dictionary. That is, ``$ typhos + ophyd.sim.SynAxis[]`` is equivalent to ``$ typhos ophyd.sim.SynAxis[{}]``. + As before, ophyd's required "name" keyword argument is filled in by typhos by + default. + + +Maintenance +----------- +- N/A + +Contributors +------------ +- klauer +- ZLLentz diff --git a/pyproject.toml b/pyproject.toml index 8466ebd28..e4b0bfbd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,9 @@ TyphosDisplaySwitcherPlugin = "typhos.display:TyphosDisplaySwitcher" TyphosDisplayTitlePlugin = "typhos.display:TyphosDisplayTitle" TyphosHelpFramePlugin = "typhos.display:TyphosHelpFrame" TyphosMethodButtonPlugin = "typhos.func:TyphosMethodButton" +TyphosNotesEditPlugin = "typhos.notes:TyphosNotesEdit" TyphosPositionerWidgetPlugin = "typhos.positioner:TyphosPositionerWidget" +TyphosPositionerRowWidgetPlugin = "typhos.positioner:TyphosPositionerRowWidget" TyphosRelatedSuiteButtonPlugin = "typhos.related_display:TyphosRelatedSuiteButton" TyphosSignalPanelPlugin = "typhos.panel:TyphosSignalPanel" diff --git a/typhos/alarm.py b/typhos/alarm.py index 8a7dc0d77..b48cd93a4 100644 --- a/typhos/alarm.py +++ b/typhos/alarm.py @@ -329,7 +329,6 @@ def update_current_alarm(self): f'Updated alarm from {self.alarm_summary} to {new_alarm} ' f'on alarm widget with channel {self.channels()[0]}' ) - self.alarm_summary = new_alarm def set_alarm_color(self, alarm_level): diff --git a/typhos/cli.py b/typhos/cli.py index cc98e6d86..5f733070b 100644 --- a/typhos/cli.py +++ b/typhos/cli.py @@ -17,14 +17,14 @@ from pydm.widgets.template_repeater import FlowLayout from qtpy import QtCore, QtWidgets -import typhos -from typhos.app import get_qapp, launch_suite -from typhos.benchmark.cases import run_benchmarks -from typhos.benchmark.profile import profiler_context -from typhos.display import DisplayTypes, ScrollOptions -from typhos.suite import TyphosSuite -from typhos.utils import (apply_standard_stylesheets, compose_stylesheets, - nullcontext) +from . import __version__ as typhos_version +from . import utils +from .app import get_qapp, launch_suite +from .benchmark.cases import run_benchmarks +from .benchmark.profile import profiler_context +from .display import DisplayTypes, ScrollOptions +from .suite import TyphosSuite +from .utils import apply_standard_stylesheets, compose_stylesheets, nullcontext logger = logging.getLogger(__name__) @@ -265,11 +265,11 @@ def _create_happi_client(cfg): """Create a happi client based on configuration ``cfg``.""" import happi - import typhos.plugins.happi + from .plugins import happi as happi_plugin - if typhos.plugins.happi.HappiClientState.client: + if happi_plugin.HappiClientState.client: logger.debug("Using happi Client already registered with Typhos") - return typhos.plugins.happi.HappiClientState.client + return happi_plugin.HappiClientState.client logger.debug("Creating new happi Client from configuration") return happi.Client.from_config(cfg=cfg) @@ -330,7 +330,7 @@ def create_suite( layout_obj = get_layout_from_cli(layout, cols) display_type_enum = get_display_type_from_cli(display_type) scroll_enum = get_scrollable_from_cli(scroll_option) - return typhos.TyphosSuite.from_devices( + return TyphosSuite.from_devices( devices, content_layout=layout_obj, default_display_type=display_type_enum, @@ -453,7 +453,7 @@ def create_devices(device_names, cfg=None, fake_devices=False): devices = list() klass_regex = re.compile( - r'([a-zA-Z][a-zA-Z0-9\.\_]*)\[(\{.+})*[\,]*\]' # noqa + r'([a-zA-Z][a-zA-Z0-9\.\_]*)\[(\{.*})*[\,]*\]' # noqa ) for device_name in device_names: @@ -567,7 +567,7 @@ def typhos_run( The window created. This is returned after the application is done running. Primarily used in unit tests. """ - with typhos.utils.no_device_lazy_load(): + with utils.no_device_lazy_load(): suite = create_suite( device_names, cfg=cfg, @@ -616,7 +616,8 @@ def typhos_cli(args): args = parser.parse_args(args, TyphosArguments()) if args.version: - print(f'Typhos: Version {typhos.__version__} from {typhos.__file__}') + typhos_file = sys.modules["typhos"].__file__ + print(f'Typhos: Version {typhos_version} from {typhos_file}') return if any( diff --git a/typhos/display.py b/typhos/display.py index 5970bb206..50e91f8ca 100644 --- a/typhos/display.py +++ b/typhos/display.py @@ -962,11 +962,6 @@ class TyphosDeviceDisplay(utils.TyphosBase, widgets.TyphosDesignerMixin, layout. If omitted, scroll_option is used instead. - composite_heuristics : bool, optional - Enable composite heuristics, which may change the suggested detailed - screen based on the contents of the added device. See also - :meth:`.suggest_composite_screen`. - embedded_templates : list, optional List of embedded templates to use in addition to those found on disk. @@ -990,9 +985,6 @@ class TyphosDeviceDisplay(utils.TyphosBase, widgets.TyphosDesignerMixin, # Template types and defaults Q_ENUMS(_DisplayTypes) TemplateEnum = DisplayTypes # For convenience - - device_count_threshold = 0 - signal_count_threshold = 30 template_changed = QtCore.Signal(object) def __init__( @@ -1000,7 +992,6 @@ def __init__( parent: Optional[QtWidgets.QWidget] = None, *, scrollable: Optional[bool] = None, - composite_heuristics: bool = True, embedded_templates: Optional[list[str]] = None, detailed_templates: Optional[list[str]] = None, engineering_templates: Optional[list[str]] = None, @@ -1008,7 +999,6 @@ def __init__( scroll_option: Union[ScrollOptions, str, int] = 'auto', nested: bool = False, ): - self._composite_heuristics = composite_heuristics self._current_template = None self._forced_template = '' self._macros = {} @@ -1021,6 +1011,12 @@ def __init__( self.templates = {name: [] for name in DisplayTypes.names} self._display_type = normalize_display_type(display_type) + if nested and self._display_type == DisplayTypes.detailed_screen: + # All nested displays should be embedded by default. + # Based on if they have subdevices, they may become detailed + # during the template loading process + self._display_type = DisplayTypes.embedded_screen + instance_templates = { 'embedded_screen': embedded_templates or [], 'detailed_screen': detailed_templates or [], @@ -1053,15 +1049,6 @@ def __init__( else: self.scroll_option = ScrollOptions.no_scroll - @Property(bool) - def composite_heuristics(self): - """Allow composite screen to be suggested first by heuristics.""" - return self._composite_heuristics - - @composite_heuristics.setter - def composite_heuristics(self, composite_heuristics): - self._composite_heuristics = bool(composite_heuristics) - @Property(_ScrollOptions) def scroll_option(self) -> ScrollOptions: """Place the display in a scrollable area.""" @@ -1087,27 +1074,31 @@ def hideEmpty(self, checked): if checked != self._hide_empty: self._hide_empty = checked + @property + def _layout_in_scroll_area(self) -> bool: + """Layout the widget in the scroll area or not, based on settings.""" + if self.scroll_option == ScrollOptions.auto: + if self.display_type == DisplayTypes.embedded_screen: + return False + return True + elif self.scroll_option == ScrollOptions.scrollbar: + return True + elif self.scroll_option == ScrollOptions.no_scroll: + return False + return True + def _move_display_to_layout(self, widget): if not widget: return widget.setParent(None) - if self.scroll_option == ScrollOptions.auto: - if self.display_type == DisplayTypes.embedded_screen: - scrollable = False - else: - scrollable = True - elif self.scroll_option == ScrollOptions.scrollbar: - scrollable = True - elif self.scroll_option == ScrollOptions.no_scroll: - scrollable = False - else: - scrollable = True + scrollable = self._layout_in_scroll_area if scrollable: self._scroll_area.setWidget(widget) else: - self.layout().addWidget(widget) + layout: QtWidgets.QVBoxLayout = self.layout() + layout.addWidget(widget, alignment=QtCore.Qt.AlignTop) self._scroll_area.setVisible(scrollable) @@ -1292,12 +1283,12 @@ def size_hint(*args, **kwargs): self.template_changed.emit(template) def minimumSizeHint(self) -> QtCore.QSize: - if self._scroll_area is None: - return super().minimumSizeHint() - return QtCore.QSize( - self._scroll_area.viewportSizeHint().width(), - super().minimumSizeHint().height(), - ) + if self._layout_in_scroll_area: + return QtCore.QSize( + self._scroll_area.viewportSizeHint().width(), + super().minimumSizeHint().height(), + ) + return super().minimumSizeHint() @property def display_widget(self): @@ -1447,12 +1438,7 @@ def search_for_templates(self): logger.debug('Adding macro template %s: %s (total=%d)', display_type, template, len(template_list)) - # 2. Composite heuristics, if enabled - if self._composite_heuristics and view == 'detailed': - if self.suggest_composite_screen(cls): - template_list.append(DETAILED_TREE_TEMPLATE) - - # 3. Templates based on class hierarchy names + # 2. Templates based on class hierarchy names filenames = utils.find_templates_for_class(cls, view, paths) for filename in filenames: if filename not in template_list: @@ -1460,6 +1446,12 @@ def search_for_templates(self): logger.debug('Found new template %s: %s (total=%d)', display_type, filename, len(template_list)) + # 3. Ensure that the detailed tree template makes its way in for + # all top-level screens, if no class-specific screen exists + if DETAILED_TREE_TEMPLATE not in template_list: + if not self._nested or self.suggest_composite_screen(cls): + template_list.append(DETAILED_TREE_TEMPLATE) + # 4. Default templates template_list.extend( [templ for templ in DEFAULT_TEMPLATES[display_type] @@ -1476,31 +1468,10 @@ def suggest_composite_screen(cls, device_cls): composite : bool If True, favor the composite screen. """ - num_devices = 0 - num_signals = 0 - for attr, component in utils._get_top_level_components(device_cls): - num_devices += issubclass(component.cls, ophyd.Device) - num_signals += issubclass(component.cls, ophyd.Signal) - - specific_screens = cls._get_specific_screens(device_cls) - if (len(specific_screens) or - (num_devices <= cls.device_count_threshold and - num_signals >= cls.signal_count_threshold)): - # 1. There's a custom screen - we probably should use them - # 2. There aren't many devices, so the composite display isn't - # useful - # 3. There are many signals, which should be broken up somehow - composite = False - else: - # 1. No custom screen, or - # 2. Many devices or a relatively small number of signals - composite = True - - logger.debug( - '%s screens=%s num_signals=%d num_devices=%d -> composite=%s', - device_cls, specific_screens, num_signals, num_devices, composite - ) - return composite + for _, component in utils._get_top_level_components(device_cls): + if issubclass(component.cls, ophyd.Device): + return True + return False @classmethod def from_device(cls, device, template=None, macros=None, **kwargs): diff --git a/typhos/notes.py b/typhos/notes.py index 6c7d712cf..f9d515edd 100644 --- a/typhos/notes.py +++ b/typhos/notes.py @@ -9,11 +9,12 @@ from pathlib import Path from typing import Dict, Optional, Tuple +import ophyd import platformdirs import yaml -from qtpy import QtCore, QtWidgets +from qtpy import QtCore, QtGui, QtWidgets -from typhos import utils +from . import utils logger = logging.getLogger(__name__) NOTES_VAR = "PCDS_DEVICE_NOTES" @@ -78,7 +79,7 @@ def get_notes_data(device_name: str) -> Tuple[NotesSource, Dict[str, str]]: # try env var env_notes_path = os.environ.get(NOTES_VAR) if env_notes_path and Path(env_notes_path).is_file(): - note_data = get_data_from_yaml(device_name, env_notes_path) + note_data = get_data_from_yaml(device_name, Path(env_notes_path)) if note_data: data = note_data source = NotesSource.ENV @@ -91,6 +92,12 @@ def get_notes_data(device_name: str) -> Tuple[NotesSource, Dict[str, str]]: data = note_data source = NotesSource.USER + logger.debug( + "Found notes for device %s from source %s: %s", + device_name, + source, + data, + ) return source, data @@ -148,37 +155,60 @@ def write_notes_data( user_data_path = platformdirs.user_data_path() / 'device_notes.yaml' insert_into_yaml(user_data_path, device_name, data) elif source == NotesSource.ENV: - env_data_path = Path(os.environ.get(NOTES_VAR)) - insert_into_yaml(env_data_path, device_name, data) + notes_var = os.environ.get(NOTES_VAR) + if not notes_var: + raise RuntimeError( + f"Unable to save notes as env var {NOTES_VAR!r} was not set" + ) + + insert_into_yaml(Path(notes_var), device_name, data) -class TyphosNotesEdit(QtWidgets.QLineEdit): +class TyphosNotesEdit( + QtWidgets.QLineEdit, + utils.TyphosBase, +): """ A QLineEdit for storing notes for a device. """ + + _qt_designer_ = { + "group": "Typhos Widgets", + "is_container": False, + } + def __init__(self, *args, refresh_time: float = 5.0, **kwargs): super().__init__(*args, **kwargs) self.editingFinished.connect(self.save_note) self.setPlaceholderText('no notes...') self.edit_filter = utils.FrameOnEditFilter(parent=self) self.setFrame(False) - self.setStyleSheet("QLineEdit { background: transparent }") - self.setReadOnly(True) self.installEventFilter(self.edit_filter) - self._last_updated: float = None + self._last_updated: Optional[float] = None self._refresh_time: float = refresh_time # to be initialized later - self.device_name: str = None + self.device_name: Optional[str] = None self.notes_source: Optional[NotesSource] = None self.data = {'note': '', 'timestamp': ''} + self.setPlaceholderText("Enter notes here") + self.setSizePolicy( + QtWidgets.QSizePolicy.MinimumExpanding, + QtWidgets.QSizePolicy.Preferred + ) def update_tooltip(self) -> None: - if self.data['note']: + if self.data['note'] and self.notes_source is not None: self.setToolTip(f"({self.data['timestamp']}, {self.notes_source.name}):\n" f"{self.data['note']}") else: self.setToolTip('click to edit note') + def add_device(self, device: ophyd.Device) -> None: + super().add_device(device) + if device is None: + return + self.setup_data(device.name) + def setup_data(self, device_name: Optional[str] = None) -> None: """ Set up the device data. Saves the device name and initializes the notes @@ -197,6 +227,10 @@ def setup_data(self, device_name: Optional[str] = None) -> None: device_name : Optional[str] The device name. Can also be a component. e.g. device_component_name """ + if device_name is None: + # Ignore the "no device" scenario + return + # if not initialized if self.device_name is None: self.device_name = device_name @@ -205,7 +239,7 @@ def setup_data(self, device_name: Optional[str] = None) -> None: if self.device_name is None: return - if not self._last_updated: + if self._last_updated is None: self._last_updated = time.time() elif (time.time() - self._last_updated) < self._refresh_time: return @@ -217,6 +251,11 @@ def setup_data(self, device_name: Optional[str] = None) -> None: self.update_tooltip() def save_note(self) -> None: + if self.notes_source is None: + raise RuntimeError("Unable to save notes - no source was defined") + if self.device_name is None: + raise RuntimeError("Unable to save notes - no device was set") + note_text = self.text() curr_time = datetime.now().ctime() self.data['note'] = note_text @@ -232,3 +271,8 @@ def event(self, event: QtCore.QEvent) -> bool: self.setup_data() return super().event(event) + + def paintEvent(self, event: QtGui.QPaintEvent) -> None: + # TyphosBase paintEvent is incompatible with this widget, somehow. + # Avoid a super() call here for now! + QtWidgets.QLineEdit.paintEvent(self, event) diff --git a/typhos/panel.py b/typhos/panel.py index 1879114af..2252e19ea 100644 --- a/typhos/panel.py +++ b/typhos/panel.py @@ -10,9 +10,12 @@ * :class:`TyphosCompositeSignalPanel` """ +from __future__ import annotations + import functools import logging from functools import partial +from typing import Optional import ophyd from ophyd import Kind @@ -454,7 +457,16 @@ def _apply_name_filter(filter_by, *items): return any(filter_by in item for item in items) - def _should_show(self, kind, name, *, kinds, name_filter): + def _should_show( + self, + kind: ophyd.Kind, + name: str, + *, + kinds: list[ophyd.Kind], + name_filter: Optional[str] = None, + show_names: Optional[list[str]] = None, + omit_names: Optional[list[str]] = None, + ): """ Based on the filter settings, indicate if ``signal`` should be shown. @@ -469,8 +481,15 @@ def _should_show(self, kind, name, *, kinds, name_filter): kinds : list of :class:`ophyd.Kind` Kinds that should be shown. - name_filter : str - Name filter text. + name_filter : str, optional + Name filter text - show only signals that match this string. This + is applied after the "omit_names" and "show_names" filters. + + show_names : list of str, optinoal + Names to explicitly show. Applied before the omit filter. + + omit_names : list of str, optinoal + Names to explicitly omit. Returns ------- @@ -478,6 +497,12 @@ def _should_show(self, kind, name, *, kinds, name_filter): """ if kind not in kinds: return False + for show_name in (show_names or []): + if show_name and show_name in name: + return True + for omit_name in (omit_names or []): + if omit_name and omit_name in name: + return False return self._apply_name_filter(name_filter, name) def _set_visible(self, signal_name, visible): @@ -528,7 +553,13 @@ def _set_visible(self, signal_name, visible): del self.signal_name_to_info[signal_name] self._connect_signal(signal) - def filter_signals(self, kinds, name_filter=None): + def filter_signals( + self, + kinds: list[ophyd.Kind], + name_filter: Optional[str] = None, + show_names: Optional[list[str]] = None, + omit_names: Optional[list[str]] = None, + ): """ Filter signals based on the given kinds. @@ -538,12 +569,25 @@ def filter_signals(self, kinds, name_filter=None): List of kinds to show. name_filter : str, optional - Additionally filter signals by name. + Name filter text - show only signals that match this string. This + is applied after the "omit_names" and "show_names" filters. + + show_names : list of str, optinoal + Names to explicitly show. Applied before the omit filter. + + omit_names : list of str, optinoal + Names to explicitly omit. """ for name, info in list(self.signal_name_to_info.items()): item = info['signal'] or info['component'] - visible = self._should_show(item.kind, name, - kinds=kinds, name_filter=name_filter) + visible = self._should_show( + item.kind, + name, + kinds=kinds, + name_filter=name_filter, + omit_names=omit_names, + show_names=show_names, + ) self._set_visible(name, visible) self.update() @@ -664,6 +708,8 @@ def __init__(self, parent=None, init_channel=None): self._panel_layout = self._panel_class() self.setLayout(self._panel_layout) self._name_filter = '' + self._show_names = [] + self._omit_names = [] # Add default Kind values self._kinds = dict.fromkeys([kind.name for kind in Kind], True) self._signal_order = SignalOrder.byKind @@ -689,6 +735,8 @@ def filter_settings(self): """Get the filter settings dictionary.""" return dict( name_filter=self.nameFilter, + omit_names=self.omitNames, + show_names=self.showNames, kinds=self.show_kinds, ) @@ -721,16 +769,38 @@ def show_kinds(self): doc='Show ophyd.Kind.omitted signals') @Property(str) - def nameFilter(self): + def nameFilter(self) -> str: """Get or set the current name filter.""" return self._name_filter @nameFilter.setter - def nameFilter(self, name_filter): + def nameFilter(self, name_filter: str): if name_filter != self._name_filter: self._name_filter = name_filter.strip() self._update_panel() + @Property("QStringList") + def omitNames(self) -> list[str]: + """Get or set the list of names to omit.""" + return self._omit_names + + @omitNames.setter + def omitNames(self, omit_names: Optional[list[str]]) -> None: + if omit_names != self._omit_names: + self._omit_names = list(omit_names or []) + self._update_panel() + + @Property("QStringList") + def showNames(self) -> list[str]: + """Get or set the list of names to omit.""" + return self._show_names + + @showNames.setter + def showNames(self, show_names: Optional[list[str]]) -> None: + if show_names != self._show_names: + self._show_names = list(show_names or []) + self._update_panel() + @Property(SignalOrder) def sortBy(self): """Get or set the order that the signals will be placed in layout.""" @@ -834,9 +904,10 @@ def add_sub_device(self, device, name): """ logger.debug('%s adding sub-device: %s (%s)', self.__class__.__name__, device.name, device.__class__.__name__) - container = display.TyphosDeviceDisplay(scrollable=False, - composite_heuristics=True, - nested=True) + container = display.TyphosDeviceDisplay( + scrollable=False, + nested=True, + ) self._containers[name] = container self.add_row(container) container.add_device(device) diff --git a/typhos/positioner.py b/typhos/positioner.py index c2ca19ace..9df8a8bf3 100644 --- a/typhos/positioner.py +++ b/typhos/positioner.py @@ -1,16 +1,71 @@ +from __future__ import annotations + import logging import os.path import threading +import typing +from typing import Optional, Union +import ophyd from pydm.widgets.channel import PyDMChannel from qtpy import QtCore, QtWidgets, uic from . import dynamic_font, utils, widgets -from .alarm import KindLevel, _KindLevel +from .alarm import AlarmLevel, KindLevel, _KindLevel +from .panel import SignalOrder, TyphosSignalPanel from .status import TyphosStatusThread logger = logging.getLogger(__name__) +if typing.TYPE_CHECKING: + import pydm.widgets + + from .alarm import TyphosAlarmRectangle + from .notes import TyphosNotesEdit + from .related_display import TyphosRelatedSuiteButton + + +class _TyphosPositionerUI(QtWidgets.QWidget): + """Annotations helper for positioner.ui; not to be instantiated.""" + + alarm_circle: TyphosAlarmRectangle + alarm_label: QtWidgets.QLabel + alarm_layout: QtWidgets.QVBoxLayout + app: QtWidgets.QApplication + clear_error_button: QtWidgets.QPushButton + device_name_label: QtWidgets.QLabel + devices: list + error_label: pydm.widgets.label.PyDMLabel + expand_button: QtWidgets.QPushButton + expert_button: TyphosRelatedSuiteButton + high_limit: pydm.widgets.label.PyDMLabel + high_limit_layout: QtWidgets.QVBoxLayout + high_limit_switch: pydm.widgets.byte.PyDMByteIndicator + horizontalLayout: QtWidgets.QHBoxLayout + low_limit: pydm.widgets.label.PyDMLabel + low_limit_layout: QtWidgets.QVBoxLayout + low_limit_switch: pydm.widgets.byte.PyDMByteIndicator + moving_indicator: pydm.widgets.byte.PyDMByteIndicator + moving_indicator_label: QtWidgets.QLabel + moving_indicator_layout: QtWidgets.QVBoxLayout + row_frame: QtWidgets.QFrame + setpoint_layout: QtWidgets.QVBoxLayout + setpoint_outer_layout: QtWidgets.QVBoxLayout + status_container_widget: QtWidgets.QWidget + status_label: QtWidgets.QLabel + status_text_layout: QtWidgets.QVBoxLayout + stop_button: QtWidgets.QPushButton + tweak_layout: QtWidgets.QHBoxLayout + tweak_negative: QtWidgets.QToolButton + tweak_positive: QtWidgets.QToolButton + tweak_value: QtWidgets.QLineEdit + tweak_widget: QtWidgets.QWidget + user_readback: pydm.widgets.label.PyDMLabel + user_setpoint: pydm.widgets.line_edit.PyDMLineEdit + + # Dynamically added: + set_value: Union[widgets.NoScrollComboBox, QtWidgets.QLineEdit] + class TyphosPositionerWidget( utils.TyphosBase, @@ -81,6 +136,7 @@ class TyphosPositionerWidget( QtCore.Q_ENUMS(_KindLevel) KindLevel = KindLevel + ui: _TyphosPositionerUI ui_template = os.path.join(utils.ui_dir, 'widgets', 'positioner.ui') _readback_attr = 'user_readback' _setpoint_attr = 'user_setpoint' @@ -94,6 +150,14 @@ class TyphosPositionerWidget( _error_message_attr = 'error_message' _min_visible_operation = 0.1 + alarm_text = { + AlarmLevel.NO_ALARM: 'no alarm', + AlarmLevel.MINOR: 'minor', + AlarmLevel.MAJOR: 'major', + AlarmLevel.DISCONNECTED: 'no conn', + AlarmLevel.INVALID: 'invalid', + } + def __init__(self, parent=None): self._moving = False self._last_move = None @@ -105,7 +169,7 @@ def __init__(self, parent=None): super().__init__(parent=parent) - self.ui = uic.loadUi(self.ui_template, self) + self.ui = typing.cast(_TyphosPositionerUI, uic.loadUi(self.ui_template, self)) self.ui.tweak_positive.clicked.connect(self.positive_tweak) self.ui.tweak_negative.clicked.connect(self.negative_tweak) self.ui.stop_button.clicked.connect(self.stop) @@ -197,6 +261,7 @@ def set(self): if not self.device: return + value = None try: if isinstance(self.ui.set_value, widgets.NoScrollComboBox): value = self.ui.set_value.currentText() @@ -358,11 +423,15 @@ def _define_setpoint_widget(self): Leverage information at describe to define whether to use a PyDMLineEdit or a PyDMEnumCombobox as setpoint widget. """ + if self.device is None: + return + try: setpoint_signal = getattr(self.device, self.setpoint_attribute) selection = setpoint_signal.enum_strs is not None except Exception: selection = False + setpoint_signal = None if selection: self.ui.set_value = widgets.NoScrollComboBox() @@ -380,7 +449,14 @@ def _define_setpoint_widget(self): self.ui.set_value.setAlignment(QtCore.Qt.AlignCenter) self.ui.set_value.returnPressed.connect(self.set) - self.ui.setpoint_layout.addWidget(self.ui.set_value) + self.ui.set_value.setMaximumWidth( + self.ui.user_setpoint.maximumWidth() + ) + + self.ui.setpoint_layout.addWidget( + self.ui.set_value, + alignment=QtCore.Qt.AlignHCenter, + ) @property def device(self): @@ -654,18 +730,266 @@ def update_alarm_text(self, alarm_level): Label the alarm circle with a short text bit. """ alarms = self.ui.alarm_circle.AlarmLevel - if alarm_level == alarms.NO_ALARM: - text = 'no alarm' - elif alarm_level == alarms.MINOR: - text = 'minor' - elif alarm_level == alarms.MAJOR: - text = 'major' - elif alarm_level == alarms.DISCONNECTED: - text = 'no conn' - else: - text = 'invalid' + try: + text = self.alarm_text[alarm_level] + except KeyError: + text = self.alarm_text[alarms.INVALID] self.ui.alarm_label.setText(text) + @property + def all_linked_attributes(self) -> list[str]: + """All linked attribute names.""" + return [ + attr + for attr in ( + self.acceleration_attribute, + self.error_message_attribute, + self.high_limit_switch_attribute, + self.high_limit_travel_attribute, + self.low_limit_switch_attribute, + self.low_limit_travel_attribute, + self.moving_attribute, + self.readback_attribute, + self.setpoint_attribute, + self.velocity_attribute, + ) + if attr + ] + + @property + def all_linked_signals(self) -> list[ophyd.Signal]: + """All linked signal names.""" + signals = [ + getattr(self.device, attr, None) + for attr in self.all_linked_attributes + ] + return [sig for sig in signals if sig is not None] + + def show_ui_type_hints(self): + """Show type hints of widgets included in the UI file.""" + cls_attrs = set() + obj_attrs = set(dir(self.ui)) + annotated = set(self.ui.__annotations__) + for cls in type(self.ui).mro(): + cls_attrs |= set(dir(cls)) + likely_from_ui = obj_attrs - cls_attrs - annotated + for attr in sorted(likely_from_ui): + try: + obj = getattr(self, attr, None) + except Exception: + ... + else: + if obj is not None: + print(f"{attr}: {obj.__class__.__module__}.{obj.__class__.__name__}") + + +class _TyphosPositionerRowUI(_TyphosPositionerUI): + """Annotations helper for positioner_row.ui; not to be instantiated.""" + + notes_edit: TyphosNotesEdit + status_container_widget: QtWidgets.QFrame + extended_signal_panel: Optional[TyphosSignalPanel] + error_prefix: QtWidgets.QLabel + + +class TyphosPositionerRowWidget(TyphosPositionerWidget): + ui: _TyphosPositionerRowUI + ui_template = os.path.join(utils.ui_dir, "widgets", "positioner_row.ui") + + alarm_text = { + AlarmLevel.NO_ALARM: 'ok', + AlarmLevel.MINOR: 'minor', + AlarmLevel.MAJOR: 'major', + AlarmLevel.DISCONNECTED: 'conn', + AlarmLevel.INVALID: 'inv', + } + + def __init__(self, *args, **kwargs): + self._error_message = "" + self._status_text = "" + self._alarm_level = AlarmLevel.DISCONNECTED + + super().__init__(*args, **kwargs) + + for idx in range(self.layout().count()): + item = self.layout().itemAt(idx) + if item is self.ui.status_text_layout: + self.layout().takeAt(idx) + break + + # TODO move these out + self._omit_names = [ + "motor_egu", + "motor_stop", + "motor_done_move", + "direction_of_travel", + "user_readback", + "user_setpoint", + "home_forward", # maybe keep? + "home_reverse", + ] + + self.ui.extended_signal_panel = None + self.ui.expand_button.clicked.connect(self._expand_layout) + self.ui.status_label.setText("") + + # TODO: ${name} / macros don't expand here + + @QtCore.Property("QStringList") + def omitNames(self) -> list[str]: + """Get or set the list of names to omit in the expanded signal panel.""" + return self._omit_names + + @omitNames.setter + def omitNames(self, omit_names: list[str]) -> None: + if omit_names == self._omit_names: + return + + self._omit_names = list(omit_names or []) + if self.ui.extended_signal_panel is not None: + self.ui.extended_signal_panel.omitNames = self._omit_names + + def get_names_to_omit(self) -> list[str]: + """ + Get a list of signal names to omit in the extended panel. + + Returns + ------- + list[str] + """ + device: Optional[ophyd.Device] = self.device + if device is None: + return [] + + omit_signals = self.all_linked_signals + to_keep_signals = [ + getattr(device, attr, None) + for attr in (self.velocity_attribute, self.acceleration_attribute) + ] + for sig in to_keep_signals: + if sig in omit_signals: + omit_signals.remove(sig) + + to_omit = set(sig.name for sig in omit_signals) + + # TODO: move these to a Qt designable property + for name in self.omitNames: + to_omit.add(name) + + if device.name in to_omit: + # Don't let the renamed position signal stop us from showing any + # signals: + to_omit.remove(device.name) + return sorted(to_omit) + + def _create_signal_panel(self) -> Optional[TyphosSignalPanel]: + """Create the 'extended' TyphosSignalPanel for the device.""" + if self.device is None: + return None + + panel = TyphosSignalPanel() + panel.omitNames = self.get_names_to_omit() + panel.sortBy = SignalOrder.byName + panel.add_device(self.device) + + self.ui.layout().addWidget(panel) + return panel + + def _expand_layout(self) -> None: + """Toggle the expansion of the signal panel.""" + if self.ui.extended_signal_panel is None: + self.ui.extended_signal_panel = self._create_signal_panel() + if self.ui.extended_signal_panel is None: + return + + to_show = True + else: + to_show = not self.ui.extended_signal_panel.isVisible() + + self.ui.extended_signal_panel.setVisible(to_show) + + if to_show: + self.ui.expand_button.setText('v') + else: + self.ui.expand_button.setText('>') + + def add_device(self, device: ophyd.Device) -> None: + """Add (or rather set) the ophyd device for this positioner.""" + super().add_device(device) + if device is None: + self.ui.device_name_label.setText("(no device)") + if self.ui.extended_signal_panel is not None: + self.layout().removeWidget(self.ui.extended_signal_panel) + self.ui.extended_signal_panel.destroyLater() + self.ui.extended_signal_panel = None + return + + self.ui.device_name_label.setText(device.name) + self.ui.notes_edit.add_device(device) + + @utils.linked_attribute('error_message_attribute', 'ui.error_label', True) + def _link_error_message(self, signal, widget): + """Link the IOC error message with the ui element.""" + if signal is None: + widget.hide() + self.ui.error_prefix.hide() + else: + signal.subscribe(self.new_error_message) + + def new_error_message(self, value, *args, **kwargs): + self.update_status_visibility(error_message=value) + + def _set_status_text(self, text, *, max_length=80): + super()._set_status_text(text, max_length=max_length) + self.update_status_visibility(status_text=text) + + def update_alarm_text(self, alarm_level): + super().update_alarm_text(alarm_level=alarm_level) + self.update_status_visibility(alarm_level=alarm_level) + + def update_status_visibility( + self, + error_message: str | None = None, + status_text: str | None = None, + alarm_level: AlarmLevel | None = None, + ) -> None: + """ + Hide/show status and error as appropriate. + + The goal here to make an illusion that there is only one label in + in this space when only one of the labels has text. + + If both are empty, we also want to put "something" there to fill the + void, so we opt for a friendly message or an alarm reminder. + """ + if error_message is not None: + self._error_message = error_message + if status_text is not None: + self._status_text = status_text + if alarm_level is not None: + self._alarm_level = alarm_level + error_message = error_message or self._error_message + status_text = status_text or self._status_text + alarm_level = alarm_level or self._alarm_level + has_status = bool(status_text) + has_error = bool(error_message) + if not has_status and not has_error: + # We want to fill something in, check if we have alarms + if alarm_level == AlarmLevel.NO_ALARM: + self.ui.status_label.setText('Status OK') + else: + self.ui.status_label.setText('Check alarm') + has_status = True + self.ui.status_label.setVisible(has_status) + self.ui.error_label.setVisible(has_error) + self.ui.error_prefix.setVisible(has_error) + + def _define_setpoint_widget(self): + super()._define_setpoint_widget() + if isinstance(self.ui.user_setpoint, QtWidgets.QLineEdit): + # Because set_value is used instead + self.ui.user_setpoint.setVisible(False) + def clear_error_in_background(device): def inner(): @@ -678,5 +1002,5 @@ def inner(): logger.error(msg) logger.debug(msg, exc_info=True) - td = threading.Thread(target=inner) + td = threading.Thread(target=inner, daemon=True) td.start() diff --git a/typhos/tests/empty_saved_suite.py b/typhos/tests/empty_saved_suite.py new file mode 100644 index 000000000..d75214358 --- /dev/null +++ b/typhos/tests/empty_saved_suite.py @@ -0,0 +1,4 @@ +# Note: this file is used in test_utils + +# Typhos expects to have saved something in this file which it will not find +# This causes ``test_load_suite_with_bad_py_file`` to fail with ``AttributeError`` diff --git a/typhos/tests/test_display.py b/typhos/tests/test_display.py index 0d97472a7..9d1d5ee4b 100644 --- a/typhos/tests/test_display.py +++ b/typhos/tests/test_display.py @@ -59,8 +59,8 @@ def check_config_panel(device): device_signals = signals_from_device(device, ophyd.Kind.config) assert device_signals == signals_from_panel('config_panel') - panel = typhos.display.TyphosDeviceDisplay.from_device( - motor, composite_heuristics=False) + panel = typhos.display.TyphosDeviceDisplay.from_device(motor) + panel.force_template = utils.ui_dir / "core" / "detailed_screen.ui" qtbot.addWidget(panel) check_hint_panel(motor) check_read_panel(motor) @@ -107,9 +107,11 @@ def test_display_modified_templates(display, motor): def test_display_force_template(display, motor): # Check that we use the forced template display.add_device(motor) - display.force_template = display.templates['engineering_screen'][0] - assert display.force_template.name == 'engineering_screen.ui' - assert display.current_template.name == 'engineering_screen.ui' + to_force = display.templates['engineering_screen'][0] + display.force_template = to_force + # Top-level screens always get detailed tree if nothing else is available + assert display.force_template.name == to_force.name + assert display.current_template.name == to_force.name def test_display_with_channel(client, qtbot): diff --git a/typhos/tests/test_utils.py b/typhos/tests/test_utils.py index a881c0189..15e8ef834 100644 --- a/typhos/tests/test_utils.py +++ b/typhos/tests/test_utils.py @@ -178,8 +178,10 @@ def test_load_suite(qtbot: pytestqt.qtbot.QtBot, happi_cfg): def test_load_suite_with_bad_py_file(): + from . import empty_saved_suite + with pytest.raises(AttributeError): - load_suite(utils.__file__) + load_suite(empty_saved_suite.__file__) def test_no_device_lazy_load(): diff --git a/typhos/ui/core/detailed_tree.ui b/typhos/ui/core/detailed_tree.ui index 9d5d8030d..acc7fe9e6 100644 --- a/typhos/ui/core/detailed_tree.ui +++ b/typhos/ui/core/detailed_tree.ui @@ -6,8 +6,8 @@ 0 0 - 395 - 468 + 771 + 93 @@ -20,6 +20,15 @@ Form + + 0 + + + 0 + + + 0 + @@ -36,7 +45,7 @@ - + @@ -57,25 +66,12 @@ - - - - Qt::Vertical - - - - 20 - 40 - - - - - TyphosSignalPanel - QWidget + TyphosCompositeSignalPanel + TyphosSignalPanel
typhos.panel
@@ -84,8 +80,8 @@
typhos.display
- TyphosCompositeSignalPanel - TyphosSignalPanel + TyphosSignalPanel + QWidget
typhos.panel
diff --git a/typhos/ui/devices/PositionerBase.detailed.ui b/typhos/ui/devices/PositionerBase.detailed.ui index 86f16e419..aeeb11276 100644 --- a/typhos/ui/devices/PositionerBase.detailed.ui +++ b/typhos/ui/devices/PositionerBase.detailed.ui @@ -7,7 +7,7 @@ 0 0 556 - 563 + 591 @@ -22,9 +22,18 @@ + + + 0 + 0 + + + + -4 + @@ -124,7 +133,7 @@ 0 0 528 - 215 + 213 @@ -221,8 +230,8 @@ 0 0 - 98 - 28 + 528 + 213 @@ -303,9 +312,9 @@ - TyphosSignalPanel - QWidget -
typhos.panel
+ TyphosDisplayTitle + QFrame +
typhos.display
TyphosPositionerWidget @@ -313,9 +322,9 @@
typhos.positioner
- TyphosDisplayTitle - QFrame -
typhos.display
+ TyphosSignalPanel + QWidget +
typhos.panel
diff --git a/typhos/ui/devices/PositionerBase.embedded.ui b/typhos/ui/devices/PositionerBase.embedded.ui index 2e4d68327..95c560fea 100644 --- a/typhos/ui/devices/PositionerBase.embedded.ui +++ b/typhos/ui/devices/PositionerBase.embedded.ui @@ -6,8 +6,8 @@ 0 0 - 337 - 258 + 872 + 58 @@ -22,110 +22,74 @@ 0 - - - 600 - 300 - - - - - 1 - 1 - - - - - 400 - 200 - - Form - + + + + 3 - - QLayout::SetDefaultConstraint - - 5 + 0 - 5 + 0 - 5 + 0 - 5 + 0 - + - - - - - - - 0 - 0 - + + user_readback - - - 16777215 - 230 - + + user_setpoint - - + + low_limit_switch - - - - - - Qt::Vertical + + high_limit_switch - - - 20 - 40 - + + low_limit_travel - - - - - - Qt::Vertical + + high_limit_travel + + + velocity - - - 20 - 40 - + + acceleration - + + true + + - TyphosPositionerWidget - QWidget + TyphosPositionerRowWidget + TyphosPositionerWidget
typhos.positioner
- TyphosDisplayTitle - QFrame -
typhos.display
+ TyphosPositionerWidget + QWidget +
typhos.positioner
diff --git a/typhos/ui/style.qss b/typhos/ui/style.qss index 999cd1ac0..d34436d65 100644 --- a/typhos/ui/style.qss +++ b/typhos/ui/style.qss @@ -49,22 +49,6 @@ TyphosBase PyDMLogDisplay > QPlainTextEdit { font: 10px normal; } -TyphosBase TyphosPositionerWidget[moving="true"] .QFrame { - border: 2px solid yellow; -} - -TyphosBase TyphosPositionerWidget[moving="false"] .QFrame { - border: 2px solid transparent; -} - -TyphosBase TyphosPositionerWidget[moving="false"][successful_move="true"] .QFrame { - border: 2px solid green; -} - -TyphosBase TyphosPositionerWidget[moving="false"][failed_move="true"] .QFrame { - border: 2px solid red; -} - SignalPanelRowLabel { font: 12px sans-serif; } @@ -87,3 +71,13 @@ QLineEdit#menu_action { TyphosLoading { color: red; } + +TyphosNotesEdit { + background: transparent; + color: black; +} + +TyphosPositionerRowWidget > TyphosNotesEdit { + background: transparent; + color: black; +} diff --git a/typhos/ui/widgets/positioner_row.ui b/typhos/ui/widgets/positioner_row.ui new file mode 100644 index 000000000..807e1ca35 --- /dev/null +++ b/typhos/ui/widgets/positioner_row.ui @@ -0,0 +1,1273 @@ + + + PositionerRowWidget + + + + 0 + 0 + 720 + 160 + + + + + 0 + 0 + + + + + 655 + 0 + + + + Form + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + + + + 0 + 0 + + + + + 60 + 35 + + + + + 16777215 + 35 + + + + + 12 + 75 + true + + + + ${name} + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + 0 + + + 0 + + + + + + + + 0 + 0 + + + + + 250 + 0 + + + + + + + + + + false + + + + + + + + + + 0 + 0 + + + + + 0 + 64 + + + + + 16777215 + 64 + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + 0 + + + + + + 30 + 30 + + + + + 30 + 30 + + + + + + + 1.000000000000000 + + + + + + + + 0 + 0 + + + + + 50 + 25 + + + + + 50 + 25 + + + + alarm + + + Qt::AlignCenter + + + true + + + + + + + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 30 + 30 + + + + + 30 + 30 + + + + + + + + Widget for graphical representation of bits from an integer number + with support for Channels and more from PyDM + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + + + false + + + false + + + + + + + + + + 20 + 100 + 239 + + + + + 60 + 60 + 60 + + + + false + + + false + + + false + + + 1 + + + 0 + + + + Bit 0 + + + + + + + + + 0 + 0 + + + + + 50 + 25 + + + + + 50 + 25 + + + + motion + + + Qt::AlignCenter + + + true + + + + + + + + 0 + 0 + + + + + 50 + 0 + + + + + 50 + 0 + + + + + + + + + + 0 + + + + + + 0 + 0 + + + + + 150 + 35 + + + + + 16777215 + 35 + + + + + 16 + 50 + false + false + + + + + + + + + + font: 16pt + + + user_readback + + + Qt::AlignCenter + + + true + + + false + + + + + + + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 25 + 25 + + + + + 25 + 25 + + + + + + + + Widget for graphical representation of bits from an integer number + with support for Channels and more from PyDM + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + + + false + + + + 239 + 246 + 20 + + + + + 178 + 188 + 17 + + + + false + + + true + + + + + + + + 0 + 0 + + + + + 40 + 25 + + + + + 40 + 25 + + + + + 8 + + + + + + + font-size: 8pt; background-color: None; color: None; + + + + + + Qt::AlignCenter + + + 1 + + + false + + + false + + + + + + + + 0 + 0 + + + + + 40 + 0 + + + + + 40 + 0 + + + + + + + + + + + 0 + 0 + + + + + 150 + 0 + + + + + 150 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 100 + 25 + + + + + 100 + 25 + + + + + + + Qt::AlignCenter + + + false + + + + + + + + + + 0 + + + QLayout::SetDefaultConstraint + + + 0 + + + 1 + + + 0 + + + 1 + + + + + + 25 + 25 + + + + - + + + + + + + + 100 + 25 + + + + + 100 + 25 + + + + Qt::AlignCenter + + + + + + + + 25 + 25 + + + + + + + + + + + + + + + + + + 0 + + + + + + 0 + 0 + + + + + 25 + 25 + + + + + 25 + 25 + + + + + + + + Widget for graphical representation of bits from an integer number + with support for Channels and more from PyDM + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + + + margin: 0px; + + + false + + + + 239 + 246 + 20 + + + + + 178 + 188 + 17 + + + + Qt::Vertical + + + false + + + true + + + 0 + + + + + + + + 0 + 0 + + + + + 40 + 25 + + + + + 40 + 25 + + + + + 8 + + + + + + + font-size: 8pt; background-color: None; color: None; + + + + + + Qt::AlignCenter + + + 1 + + + false + + + false + + + + + + + + 0 + 0 + + + + + 40 + 0 + + + + + 40 + 0 + + + + + + + + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 70 + 50 + + + + + 70 + 50 + + + + + 75 + true + + + + padding: 2px; margin: 0px; background-color: red + + + Stop + + + false + + + false + + + false + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 70 + 0 + + + + + + + + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 70 + 50 + + + + + 70 + 50 + + + + + 75 + true + + + + + + + + + + Expert +Screen + + + + ../../../../../.designer/backup../../../../../.designer/backup + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 70 + 16777215 + + + + + + + + + + + + + + 16777215 + 16777215 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + 0 + 0 + + + + + 25 + 25 + + + + + 25 + 25 + + + + Expand Details + + + > + + + false + + + false + + + + + + + 3 + + + + + + 0 + 0 + + + + + 0 + 25 + + + + + 16777215 + 25 + + + + color: black; background-color: none; border-radius: 0px; + + + (Status label) + + + true + + + 0 + + + + + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 0 + 25 + + + + + 16777215 + 25 + + + + color: red + + + Error: + + + + + + + + 0 + 0 + + + + + 0 + 25 + + + + + 16777215 + 25 + + + + + + + color: black; background-color: none; border-radius: 0px; + + + + + + true + + + 0 + + + PyDMLabel::String + + + + + + + + + + + + 0 + 0 + + + + + 143 + 42 + + + + + 143 + 42 + + + + + 75 + true + + + + + + + + + + Clear Error + + + + + + + + + + + + + PyDMLabel + QLabel +
pydm.widgets.label
+
+ + PyDMByteIndicator + QWidget +
pydm.widgets.byte
+
+ + PyDMLineEdit + QLineEdit +
pydm.widgets.line_edit
+
+ + TyphosAlarmRectangle + QWidget +
typhos.alarm
+
+ + TyphosNotesEdit + QLineEdit +
typhos.notes
+
+ + TyphosRelatedSuiteButton + QPushButton +
typhos.related_display
+
+
+ + +
diff --git a/typhos/utils.py b/typhos/utils.py index d9740950c..d943994b1 100644 --- a/typhos/utils.py +++ b/typhos/utils.py @@ -38,7 +38,7 @@ from qtpy.QtGui import QColor, QMovie, QPainter from qtpy.QtWidgets import QWidget -from typhos import plugins +from . import plugins try: import happi @@ -1714,17 +1714,29 @@ def take_widget_screenshot(widget: QtWidgets.QWidget) -> Optional[QtGui.QImage]: # No apps, no screenshots! return None - primary_screen = app.primaryScreen() - logger.debug("Primary screen: %s", primary_screen) + try: + primary_screen: QtGui.QScreen = app.primaryScreen() + logger.debug("Primary screen: %s", primary_screen) - screen = ( - widget.screen() - if hasattr(widget, "screen") - else primary_screen - ) + screen = ( + widget.screen() + if hasattr(widget, "screen") + else primary_screen + ) - logger.info("Primary screen: %s widget screen: %s", primary_screen, screen) - return screen.grabWindow(widget.winId()) + logger.debug( + "Screenshot: %s (%s, primary screen: %s widget screen: %s)", + widget.windowTitle(), + widget, + primary_screen.name(), + screen.name(), + ) + return screen.grabWindow(widget.winId()) + except RuntimeError as ex: + # The widget may have been deleted already; do not fail in this + # scenario. + logger.debug("Widget %s screenshot failed due to: %s", type(widget), ex) + return None def take_top_level_widget_screenshots( @@ -1760,6 +1772,19 @@ def by_title(widget): return widget.windowTitle() or str(id(widget)) for widget in sorted(app.topLevelWidgets(), key=by_title): - if visible_only and not widget.isVisible(): - continue - yield widget, take_widget_screenshot(widget) + if isinstance(widget, QtWidgets.QMenu): + try: + logger.debug( + "Skipping QMenu for screenshots. %s parent=%s", + widget, + widget.parent(), + ) + except RuntimeError: + # Widget could have been gc'd in the meantime + pass + elif visible_only and not widget.isVisible(): + ... + else: + image = take_widget_screenshot(widget) + if image is not None: + yield widget, image