"""Contains the main display widget used for representing an entire device."""
import enum
import inspect
import logging
import os
import pathlib
import webbrowser
from typing import Optional, Union
import ophyd
import pcdsutils
import pydm.display
import pydm.exception
import pydm.utilities
from pcdsutils.qt import forward_property
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Q_ENUMS, Property, Qt, Slot
from . import cache
from . import panel as typhos_panel
from . import utils, web, widgets
from .jira import TyphosJiraIssueWidget
from .plugins.core import register_signal
logger = logging.getLogger(__name__)
class DisplayTypes(enum.IntEnum):
"""Enumeration of template types that can be used in displays."""
embedded_screen = 0
detailed_screen = 1
engineering_screen = 2
_DisplayTypes = utils.pyqt_class_from_enum(DisplayTypes)
DisplayTypes.names = [view.name for view in DisplayTypes]
class ScrollOptions(enum.IntEnum):
"""Enumeration of scrollable options for displays."""
auto = 0
scrollbar = 1
no_scroll = 2
_ScrollOptions = utils.pyqt_class_from_enum(ScrollOptions)
ScrollOptions.names = [view.name for view in ScrollOptions]
DEFAULT_TEMPLATES = {
name: [(utils.ui_dir / 'core' / f'{name}.ui').resolve()]
for name in DisplayTypes.names
}
DETAILED_TREE_TEMPLATE = (utils.ui_dir / 'core' / 'detailed_tree.ui').resolve()
DEFAULT_TEMPLATES['detailed_screen'].append(DETAILED_TREE_TEMPLATE)
DEFAULT_TEMPLATES_FLATTEN = [f for _, files in DEFAULT_TEMPLATES.items()
for f in files]
[docs]def normalize_display_type(
display_type: Union[DisplayTypes, str, int]
) -> DisplayTypes:
"""
Normalize a given display type.
Parameters
----------
display_type : DisplayTypes, str, or int
The display type.
Returns
-------
display_type : DisplayTypes
The normalized :class:`DisplayTypes`.
Raises
------
ValueError
If the input cannot be made a :class:`DisplayTypes`.
"""
try:
return DisplayTypes(display_type)
except ValueError:
try:
return DisplayTypes[display_type]
except KeyError:
raise ValueError(
f'Unrecognized display type: {display_type}'
)
def normalize_scroll_option(
scroll_option: Union[ScrollOptions, str, int]
) -> ScrollOptions:
"""
Normalize a given scroll option.
Parameters
----------
display_type : ScrollOptions, str, or int
The display type.
Returns
-------
display_type : ScrollOptions
The normalized :class:`ScrollOptions`.
Raises
------
ValueError
If the input cannot be made a :class:`ScrollOptions`.
"""
try:
return ScrollOptions(scroll_option)
except ValueError:
try:
return ScrollOptions[scroll_option]
except KeyError:
raise ValueError(
f'Unrecognized scroll option: {scroll_option}'
)
[docs]class TyphosDisplaySwitcher(QtWidgets.QFrame, widgets.TyphosDesignerMixin):
"""Display switcher set of buttons for use with a TyphosDeviceDisplay."""
template_selected = QtCore.Signal(pathlib.Path)
def __init__(self, parent=None, **kwargs):
# Intialize background variable
super().__init__(parent=None)
self.device_display = None
self.buttons = {}
layout = QtWidgets.QHBoxLayout()
self.setLayout(layout)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
self.setContextMenuPolicy(Qt.DefaultContextMenu)
self.contextMenuEvent = self.open_context_menu
if parent:
self.setParent(parent)
self._create_ui()
def _create_ui(self):
layout = self.layout()
self.buttons.clear()
self.help_button = None
self.config_button = None
self.help_toggle_button = TyphosHelpToggleButton()
layout.addWidget(self.help_toggle_button, 0, Qt.AlignRight)
for template_type in DisplayTypes.names:
button = TyphosDisplaySwitcherButton(template_type)
self.buttons[template_type] = button
button.template_selected.connect(self._template_selected)
layout.addWidget(button, 0, Qt.AlignRight)
friendly_name = template_type.replace('_', ' ')
button.setToolTip(f'Switch to {friendly_name}')
self.config_button = TyphosDisplayConfigButton()
layout.addWidget(self.config_button, 0, Qt.AlignRight)
self.config_button.setToolTip('Display settings...')
def _template_selected(self, template):
"""Template selected hook."""
self.template_selected.emit(template)
if self.device_display is not None:
self.device_display.force_template = template
[docs] def set_device_display(self, display):
"""Typhos hook for setting the associated device display."""
self.device_display = display
for template_type in self.buttons:
templates = display.templates.get(template_type, [])
self.buttons[template_type].templates = templates
self.config_button.set_device_display(display)
[docs] def add_device(self, device):
"""Typhos hook for setting the associated device."""
...
[docs]class TyphosTitleLabel(QtWidgets.QLabel):
"""
A label class intended for use as a standardized title.
Attributes
----------
toggle_requested : QtCore.Signal
A Qt signal indicating that the user clicked on the title. By default,
this hides any nested panels underneath the title.
"""
toggle_requested = QtCore.Signal()
[docs] def mousePressEvent(self, event):
"""Overridden qt hook for a mouse press."""
if event.button() == Qt.LeftButton:
self.toggle_requested.emit()
super().mousePressEvent(event)
class TyphosHelpToggleButton(TyphosToolButton):
"""
A standard button used to toggle help information display.
Attributes
----------
pop_out : QtCore.Signal
A Qt signal indicating a request to pop out the help widget.
open_in_browser : QtCore.Signal
A Qt signal indicating a request to open the help in a browser.
open_python_docs : QtCore.Signal
A Qt signal indicating a request to open the Python docstring
information.
report_jira_issue : QtCore.Signal
A Qt signal indicating a request to open the Jira issue reporting
widget.
toggle_help : QtCore.Signal
A Qt signal indicating a request to toggle the related help display
frame.
"""
pop_out = QtCore.Signal()
open_in_browser = QtCore.Signal()
open_python_docs = QtCore.Signal()
report_jira_issue = QtCore.Signal()
toggle_help = QtCore.Signal(bool)
def __init__(self, icon="question", parent=None):
super().__init__(icon, parent=parent)
self.setCheckable(True)
def _clicked(self):
"""Hook for QToolButton.clicked."""
self.toggle_help.emit(self.isChecked())
def generate_context_menu(self):
menu = QtWidgets.QMenu(parent=self)
if utils.HELP_WEB_ENABLED:
pop_out_docs = menu.addAction("Pop &out documentation...")
pop_out_docs.triggered.connect(self.pop_out.emit)
open_in_browser = menu.addAction("Open in &browser...")
open_in_browser.triggered.connect(self.open_in_browser.emit)
open_python_docs = menu.addAction("Open &Python docs...")
open_python_docs.triggered.connect(self.open_python_docs.emit)
def toggle():
self.setChecked(not self.isChecked())
self._clicked()
if utils.HELP_WEB_ENABLED:
toggle_help = menu.addAction("Toggle &help")
toggle_help.triggered.connect(toggle)
if utils.JIRA_URL:
menu.addSeparator()
report_issue = menu.addAction("&Report Jira issue...")
report_issue.triggered.connect(self.report_jira_issue.emit)
return menu
class TyphosHelpFrame(QtWidgets.QFrame, widgets.TyphosDesignerMixin):
"""
A frame for help information display.
Attributes
----------
tooltip_updated : QtCore.Signal
A signal indicating the help tooltip has changed.
"""
tooltip_updated = QtCore.Signal(str)
def __init__(self, parent=None):
super().__init__(parent=parent)
self.help = None
self.help_web_view = None
self._delete_timer = None
self.python_docs_browser = None
self.setContentsMargins(0, 0, 0, 0)
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
self.devices = []
self._jira_widget = None
def new_jira_widget(self):
"""Open a new Jira issue reporting widget."""
device = self.devices[0] if self.devices else None
self._jira_widget = TyphosJiraIssueWidget(device=device)
self._jira_widget.show()
def open_in_browser(self, new=0, autoraise=True):
"""
Open the associated help documentation in the browser.
Parameters
----------
new : int, optional
0: the same browser window (the default).
1: a new browser window.
2: a new browser page ("tab").
autoraise : bool, optional
If possible, autoraise raises the window (the default) or not.
"""
return webbrowser.open(
self.help_url.toString(), new=new, autoraise=autoraise
)
def open_python_docs(self, show: bool = True):
"""Open the Python docstring information in a new window."""
if self.python_docs_browser is not None:
if show:
self.python_docs_browser.show()
self.python_docs_browser.raise_()
else:
self.python_docs_browser.hide()
return
if not show:
return
self.python_docs_browser = QtWidgets.QTextBrowser()
help_document = QtGui.QTextDocument()
contents = self._tooltip or "Unset"
first_line = contents.splitlines()[0]
# TODO: later versions of qt will support setMarkdown
help_document.setPlainText(contents)
self.python_docs_browser.setWindowTitle(first_line)
font = QtGui.QFont("Monospace")
font.setStyleHint(QtGui.QFont.TypeWriter)
# font.setStyleHint(QtGui.QFont.Monospace)
self.python_docs_browser.setFont(font)
self.python_docs_browser.setDocument(help_document)
self.python_docs_browser.show()
return self.python_docs_browser
def _get_tooltip(self):
"""Update the tooltip based on device information."""
tooltip = []
# BUG: I'm seeing two devices in `self.devices` for
# $ typhos --fake-device 'ophyd.EpicsMotor[{"prefix":"b"}]'
for device in sorted(
set(self.devices),
key=lambda dev: self.devices.index(dev)
):
heading = device.name or type(device).__name__
tooltip.extend([
heading,
"-" * len(heading),
""
])
tooltip.append(
inspect.getdoc(device) or
inspect.getdoc(type(device)) or
"No docstring"
)
tooltip.append("")
return "\n".join(tooltip)
def add_device(self, device):
self.devices.append(device)
self._tooltip = self._get_tooltip()
self.tooltip_updated.emit(self._tooltip)
self.setWindowTitle(f"Help: {device.name}")
@property
def help_url(self):
"""The full help URL, generated from ``TYPHOS_HELP_URL``."""
if not self.devices or not utils.HELP_WEB_ENABLED:
return QtCore.QUrl("about:blank")
device, *_ = self.devices
try:
device_url = utils.HELP_URL.format(device=device)
except Exception:
logger.exception("Failed to format confluence URL for device %s",
device)
return QtCore.QUrl("about:blank")
return QtCore.QUrl(device_url)
def show_help(self):
"""Show the help information in a QWebEngineView."""
if web.TyphosWebEngineView is None:
logger.error(
"Failed to import QWebEngineView; "
"help view is unavailable."
)
return
if self.help_web_view:
self.help_web_view.show()
return
self.help_web_view = web.TyphosWebEngineView()
self.help_web_view.page().setUrl(self.help_url)
self.help_web_view.setEnabled(True)
self.help_web_view.setMinimumSize(QtCore.QSize(100, 400))
self.layout().addWidget(self.help_web_view)
def hide_help(self):
"""Hide the help information QWebEngineView."""
if not self.help_web_view:
return
self.help_web_view.hide()
if self._delete_timer is None:
self._delete_timer = QtCore.QTimer()
self._delete_timer.setInterval(20000)
self._delete_timer.setSingleShot(True)
self._delete_timer.timeout.connect(self._delete_help_if_hidden)
self._delete_timer.start()
def _delete_help_if_hidden(self):
"""
Slowly react to the help display removal, as setting it back up can be
slow and painful.
"""
self._delete_timer = None
if self.help_web_view and not self.help_web_view.isVisible():
self.layout().removeWidget(self.help_web_view)
self.help_web_view.deleteLater()
self.help_web_view = None
def toggle_help(self, show):
"""
Toggle the visibility of the help information QWebEngineView.
Parameters
----------
show : bool
Show the help (True) or hide it (False).
"""
if not self.devices:
logger.warning("No devices added -> no help")
return
if show:
self.show_help()
else:
self.hide_help()
[docs]class TyphosDisplayTitle(QtWidgets.QFrame, widgets.TyphosDesignerMixin):
"""
Standardized Typhos Device Display title.
Parameters
----------
title : str, optional
The initial title text, which may contain macros.
show_switcher : bool, optional
Show the :class:`TyphosDisplaySwitcher`.
show_underline : bool, optional
Show the underline separator.
parent : QtWidgets.QWidget, optional
The parent widget.
"""
def __init__(self, title='${name}', *, show_switcher=True,
show_underline=True, parent=None):
self._show_underline = show_underline
self._show_switcher = show_switcher
super().__init__(parent=parent)
self.label = TyphosTitleLabel(title)
self.switcher = TyphosDisplaySwitcher()
self.underline = QtWidgets.QFrame()
self.underline.setFrameShape(self.underline.HLine)
self.underline.setFrameShadow(self.underline.Plain)
self.underline.setLineWidth(10)
self.grid_layout = QtWidgets.QGridLayout()
self.grid_layout.addWidget(self.label, 0, 0)
self.grid_layout.addWidget(self.switcher, 0, 1, Qt.AlignRight)
self.grid_layout.addWidget(self.underline, 1, 0, 1, 2)
self.help = TyphosHelpFrame()
if utils.HELP_WEB_ENABLED:
# Toggle the help web view if we have documentation to show
self.switcher.help_toggle_button.toggle_help.connect(
self.toggle_help
)
else:
# Otherwise, open the python docs as a fallback
self.switcher.help_toggle_button.toggle_help.connect(
self.help.open_python_docs
)
self.switcher.help_toggle_button.pop_out.connect(self.pop_out_help)
self.switcher.help_toggle_button.open_in_browser.connect(
self.help.open_in_browser
)
self.switcher.help_toggle_button.open_python_docs.connect(
self.help.open_python_docs
)
self.switcher.help_toggle_button.report_jira_issue.connect(
self.help.new_jira_widget
)
self.help.tooltip_updated.connect(
self.switcher.help_toggle_button.setToolTip
)
self.grid_layout.addWidget(self.help, 2, 0, 1, 2)
self.grid_layout.setSizeConstraint(self.grid_layout.SetMinimumSize)
self.setLayout(self.grid_layout)
# Set the property:
self.show_switcher = show_switcher
self.show_underline = show_underline
[docs] def toggle_help(self, show):
"""Toggle the help visibility."""
if self.help is None:
return
self.help.toggle_help(show)
if self.help.parent() is None:
self.grid_layout.addWidget(self.help, 2, 0, 1, 2)
[docs] def pop_out_help(self):
"""Pop out the help widget."""
if self.help is None:
return
self.help.setParent(None)
self.switcher.help_toggle_button.setChecked(True)
self.help.show_help()
self.help.show()
self.help.raise_()
@Property(bool)
def show_switcher(self):
"""Get or set whether to show the display switcher."""
return self._show_switcher
@show_switcher.setter
def show_switcher(self, value):
self._show_switcher = bool(value)
self.switcher.setVisible(self._show_switcher)
[docs] def add_device(self, device):
"""Typhos hook for setting the associated device."""
if not self.label.text():
self.label.setText(device.name)
if self.help is not None:
self.help.add_device(device)
@QtCore.Property(bool)
def show_underline(self):
"""Get or set whether to show the underline."""
return self._show_underline
@show_underline.setter
def show_underline(self, value):
self._show_underline = bool(value)
self.underline.setVisible(self._show_underline)
[docs] def set_device_display(self, display):
"""Typhos callback: set the :class:`TyphosDeviceDisplay`."""
self.device_display = display
def toggle():
toggle_display(display.display_widget)
self.label.toggle_requested.connect(toggle)
# Make designable properties from the title label available here as well
label_alignment = forward_property('label', QtWidgets.QLabel, 'alignment')
label_font = forward_property('label', QtWidgets.QLabel, 'font')
label_indent = forward_property('label', QtWidgets.QLabel, 'indent')
label_margin = forward_property('label', QtWidgets.QLabel, 'margin')
label_openExternalLinks = forward_property('label', QtWidgets.QLabel,
'openExternalLinks')
label_pixmap = forward_property('label', QtWidgets.QLabel, 'pixmap')
label_text = forward_property('label', QtWidgets.QLabel, 'text')
label_textFormat = forward_property('label', QtWidgets.QLabel,
'textFormat')
label_textInteractionFlags = forward_property('label', QtWidgets.QLabel,
'textInteractionFlags')
label_wordWrap = forward_property('label', QtWidgets.QLabel, 'wordWrap')
# Make designable properties from the grid_layout
layout_margin = forward_property('grid_layout', QtWidgets.QHBoxLayout,
'margin')
layout_spacing = forward_property('grid_layout', QtWidgets.QHBoxLayout,
'spacing')
# Make designable properties from the underline
underline_palette = forward_property('underline', QtWidgets.QFrame,
'palette')
underline_styleSheet = forward_property('underline', QtWidgets.QFrame,
'styleSheet')
underline_lineWidth = forward_property('underline', QtWidgets.QFrame,
'lineWidth')
underline_midLineWidth = forward_property('underline', QtWidgets.QFrame,
'midLineWidth')
[docs]class TyphosDeviceDisplay(utils.TyphosBase, widgets.TyphosDesignerMixin,
_DisplayTypes):
"""
Main display for a single ophyd Device.
This contains the widgets for all of the root devices signals, and any
methods you would like to display. By typhos convention, the base
initialization sets up the widgets and the :meth:`.from_device` class
method will automatically populate the resulting display.
Parameters
----------
parent : QWidget, optional
The parent widget.
scrollable : bool, optional
Semi-deprecated parameter. Use scroll_option instead.
If ``True``, put the loaded template into a :class:`QScrollArea`.
If ``False``, the display widget will go directly in this widget's
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.
detailed_templates : list, optional
List of detailed templates to use in addition to those found on disk.
engineering_templates : list, optional
List of engineering templates to use in addition to those found on
disk.
display_type : DisplayTypes, str, or int, optional
The default display type.
scroll_option : ScrollOptions, str, or int, optional
The scroll behavior.
nested : bool, optional
An optional annotation for a display that may be nested inside another.
"""
# Template types and defaults
Q_ENUMS(_DisplayTypes)
TemplateEnum = DisplayTypes # For convenience
device_count_threshold = 0
signal_count_threshold = 30
def __init__(
self,
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,
display_type: Union[DisplayTypes, str, int] = 'detailed_screen',
scroll_option: Union[ScrollOptions, str, int] = 'auto',
nested: bool = False,
):
self._composite_heuristics = composite_heuristics
self._current_template = None
self._forced_template = ''
self._macros = {}
self._display_widget = None
self._scroll_option = ScrollOptions.no_scroll
self._searched = False
self._hide_empty = False
self._nested = nested
self.templates = {name: [] for name in DisplayTypes.names}
self._display_type = normalize_display_type(display_type)
instance_templates = {
'embedded_screen': embedded_templates or [],
'detailed_screen': detailed_templates or [],
'engineering_screen': engineering_templates or [],
}
for view, path_list in instance_templates.items():
paths = [pathlib.Path(p).expanduser().resolve() for p in path_list]
self.templates[view].extend(paths)
self._scroll_area = QtWidgets.QScrollArea()
self._scroll_area.setAlignment(Qt.AlignTop)
self._scroll_area.setObjectName('scroll_area')
self._scroll_area.setFrameShape(QtWidgets.QFrame.StyledPanel)
self._scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._scroll_area.setWidgetResizable(True)
self._scroll_area.setFrameStyle(QtWidgets.QFrame.NoFrame)
super().__init__(parent=parent)
layout = QtWidgets.QHBoxLayout()
self.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._scroll_area)
if scrollable is None:
self.scroll_option = scroll_option
else:
if scrollable:
self.scroll_option = ScrollOptions.scrollbar
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."""
return self._scroll_option
@scroll_option.setter
def scroll_option(self, scrollable: ScrollOptions):
# Switch the scroll area behavior
opt = normalize_scroll_option(scrollable)
if opt == self._scroll_option:
return
self._scroll_option = opt
self._move_display_to_layout(self._display_widget)
@Property(bool)
def hideEmpty(self):
"""Toggle hiding or showing empty panels."""
return self._hide_empty
@hideEmpty.setter
def hideEmpty(self, checked):
if checked != self._hide_empty:
self._hide_empty = checked
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
if scrollable:
self._scroll_area.setWidget(widget)
else:
self.layout().addWidget(widget)
self._scroll_area.setVisible(scrollable)
def _generate_template_menu(self, base_menu):
"""Generate the template switcher menu, adding it to ``base_menu``."""
for view, filenames in self.templates.items():
if view.endswith('_screen'):
view = view.split('_screen')[0]
menu = base_menu.addMenu(view.capitalize())
for filename in filenames:
def switch_template(*, filename=filename):
self.force_template = filename
action = menu.addAction(os.path.split(filename)[-1])
action.triggered.connect(switch_template)
refresh_action = base_menu.addAction("Refresh Templates")
refresh_action.triggered.connect(self._refresh_templates)
def _refresh_templates(self):
"""Context menu 'Refresh Templates' clicked."""
# Force an update of the display cache.
cache.get_global_display_path_cache().update()
self.search_for_templates()
self.load_best_template()
@property
def current_template(self):
"""Get the current template being displayed."""
return self._current_template
@Property(_DisplayTypes)
def display_type(self):
"""Get or set the current display type."""
return self._display_type
@display_type.setter
def display_type(self, value):
value = normalize_display_type(value)
if self._display_type != value:
self._display_type = value
self.load_best_template()
@property
def macros(self):
"""Get or set the macros for the display."""
return dict(self._macros)
@macros.setter
def macros(self, macros):
self._macros.clear()
self._macros.update(**(macros or {}))
# If any display macros are specified, re-search for templates:
if any(view in self._macros for view in DisplayTypes.names):
self.search_for_templates()
@Property(str, designable=False)
def device_class(self):
"""Get the full class with module name of loaded device."""
device = self.device
cls = self.device.__class__
return f'{cls.__module__}.{cls.__name__}' if device else ''
@Property(str, designable=False)
def device_name(self):
"""Get the name of the loaded device."""
device = self.device
return device.name if device else ''
@property
def device(self):
"""Get the device associated with this Device Display."""
try:
device, = self.devices
return device
except ValueError:
...
[docs] def get_best_template(self, display_type, macros):
"""
Get the best template for the given display type.
Parameters
----------
display_type : DisplayTypes, str, or int
The display type.
macros : dict
Macros to use when loading the template.
"""
display_type = normalize_display_type(display_type).name
templates = self.templates[display_type]
if templates:
return templates[0]
logger.warning("No templates available for display type: %s",
self._display_type)
def _remove_display(self):
"""Remove the display widget, readying for a new template."""
display_widget = self._display_widget
if display_widget:
if self._scroll_area.widget():
self._scroll_area.takeWidget()
self.layout().removeWidget(display_widget)
display_widget.deleteLater()
self._display_widget = None
[docs] def load_best_template(self):
"""Load the best available template for the current display type."""
if self.layout() is None:
# If we are not fully initialized yet do not try and add anything
# to the layout. This will happen if the QApplication has a
# stylesheet that forces a template prior to the creation of this
# display
return
if not self._searched:
self.search_for_templates()
self._remove_display()
template = (self._forced_template or
self.get_best_template(self._display_type, self.macros))
if not template:
widget = QtWidgets.QWidget()
template = None
else:
template = pathlib.Path(template)
try:
widget = self._load_template(template)
except Exception as ex:
logger.exception("Unable to load file %r", template)
# If we have a previously defined template
if self._current_template is not None:
# Fallback to it so users have a choice
try:
widget = self._load_template(self._current_template)
except Exception:
logger.exception(
"Failed to fall back to previous template: %s",
self._current_template
)
template = None
widget = None
pydm.exception.raise_to_operator(ex)
else:
widget = QtWidgets.QWidget()
template = None
if widget:
widget.setObjectName('display_widget')
if widget.layout() is None and widget.minimumSize().width() == 0:
# If the widget has no layout, use a fixed size for it.
# Without this, the widget may not display at all.
widget.setMinimumSize(widget.size())
self._display_widget = widget
self._current_template = template
def size_hint(*args, **kwargs):
return widget.size()
# sizeHint is not defined so we suggest the widget size
widget.sizeHint = size_hint
# We should _move_display_to_layout as soon as it is created. This
# allow us to speed up since if the widget is too complex it takes
# seconds to set it to the QScrollArea
self._move_display_to_layout(self._display_widget)
self._update_children()
utils.reload_widget_stylesheet(self)
@property
def display_widget(self):
"""Get the widget generated from the template."""
return self._display_widget
@staticmethod
def _get_templates_from_macros(macros):
ret = {}
paths = cache.get_global_display_path_cache().paths
for display_type in DisplayTypes.names:
ret[display_type] = None
try:
value = macros[display_type]
except KeyError:
...
else:
if not value:
continue
try:
value = pathlib.Path(value)
except ValueError as ex:
logger.debug('Invalid path specified in macro: %s=%s',
display_type, value, exc_info=ex)
else:
ret[display_type] = list(utils.find_file_in_paths(
value, paths=paths))
return ret
def _load_template(self, filename):
"""Load template from file and return the widget."""
filename = pathlib.Path(filename)
loader = (pydm.display.load_py_file if filename.suffix == '.py'
else pydm.display.load_ui_file)
logger.debug('Load template using %s: %r', loader.__name__, filename)
return loader(str(filename), macros=self._macros)
def _update_children(self):
"""Notify child widgets of this device display + the device."""
device = self.device
display = self._display_widget
designer = display.findChildren(widgets.TyphosDesignerMixin) or []
bases = display.findChildren(utils.TyphosBase) or []
for widget in set(bases + designer):
if device and hasattr(widget, 'add_device'):
widget.add_device(device)
if hasattr(widget, 'set_device_display'):
widget.set_device_display(self)
@Property(str)
def force_template(self):
"""Force a specific template."""
return self._forced_template
@force_template.setter
def force_template(self, value):
if value != self._forced_template:
self._forced_template = value
self.load_best_template()
@staticmethod
def _build_macros_from_device(device, macros=None):
result = {}
if hasattr(device, 'md'):
if isinstance(device.md, dict):
result = dict(device.md)
else:
result = dict(device.md.post())
if 'name' not in result:
result['name'] = device.name
if 'prefix' not in result and hasattr(device, 'prefix'):
result['prefix'] = device.prefix
result.update(**(macros or {}))
return result
[docs] def add_device(self, device, macros=None):
"""
Add a Device and signals to the TyphosDeviceDisplay.
The full dictionary of macros is built with the following order of
precedence::
1. Macros from the device metadata itself.
2. If available, `name`, and `prefix` will be added from the device.
3. The argument ``macros`` is then used to fill/update the final
macro dictionary.
This will also register the device's signals in the sig:// plugin.
This means that any templates can refer to their device's signals by
name.
Parameters
----------
device : ophyd.Device
The device to add.
macros : dict, optional
Additional macros to use/replace the defaults.
"""
# We only allow one device at a time
if self.devices:
logger.debug("Removing devices %r", self.devices)
self.devices.clear()
# Add the device to the cache
super().add_device(device)
logger.debug("Registering signals from device %s", device.name)
for component_walk in device.walk_signals():
register_signal(component_walk.item)
self._searched = False
self.macros = self._build_macros_from_device(device, macros=macros)
self.load_best_template()
[docs] def search_for_templates(self):
"""Search the filesystem for device-specific templates."""
device = self.device
if not device:
logger.debug('Cannot search for templates without device')
return
self._searched = True
cls = device.__class__
logger.debug('Searching for templates for %s', cls.__name__)
macro_templates = self._get_templates_from_macros(self._macros)
paths = cache.get_global_display_path_cache().paths
for display_type in DisplayTypes.names:
view = display_type
if view.endswith('_screen'):
view = view.split('_screen')[0]
template_list = self.templates[display_type]
template_list.clear()
# 1. Highest priority: macros
for template in set(macro_templates[display_type] or []):
template_list.append(template)
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
filenames = utils.find_templates_for_class(cls, view, paths)
for filename in filenames:
if filename not in template_list:
template_list.append(filename)
logger.debug('Found new template %s: %s (total=%d)',
display_type, filename, len(template_list))
# 4. Default templates
template_list.extend(
[templ for templ in DEFAULT_TEMPLATES[display_type]
if templ not in template_list]
)
[docs] @classmethod
def suggest_composite_screen(cls, device_cls):
"""
Suggest to use the composite screen for the given class.
Returns
-------
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
[docs] @classmethod
def from_device(cls, device, template=None, macros=None, **kwargs):
"""
Create a new TyphosDeviceDisplay from a Device.
Loads the signals in to the appropriate positions and sets the title to
a cleaned version of the device name
Parameters
----------
device : ophyd.Device
template : str, optional
Set the ``display_template``.
macros : dict, optional
Macro substitutions to be placed in template.
**kwargs
Passed to the class init.
"""
display = cls(**kwargs)
# Reset the template if provided
if template:
display.force_template = template
# Add the device
display.add_device(device, macros=macros)
return display
[docs] @classmethod
def from_class(cls, klass, *, template=None, macros=None, **kwargs):
"""
Create a new TyphosDeviceDisplay from a Device class.
Loads the signals in to the appropriate positions and sets the title to
a cleaned version of the device name.
Parameters
----------
klass : str or class
template : str, optional
Set the ``display_template``.
macros : dict, optional
Macro substitutions to be placed in template.
**kwargs
Extra arguments are used at device instantiation.
Returns
-------
TyphosDeviceDisplay
"""
try:
obj = pcdsutils.utils.get_instance_by_name(klass, **kwargs)
except Exception:
logger.exception('Failed to generate TyphosDeviceDisplay from '
'class %s', klass)
return None
return cls.from_device(obj, template=template, macros=macros)
@classmethod
def _get_specific_screens(cls, device_cls):
"""
Get the list of specific screens for a given device class.
That is, screens that are not default Typhos-provided screens.
"""
paths = cache.get_global_display_path_cache().paths
return [
template
for template in utils.find_templates_for_class(
device_cls, "detailed", paths
)
if not utils.is_standard_template(template)
]
[docs] def to_image(self):
"""
Return the entire display as a QtGui.QImage.
Returns
-------
QtGui.QImage
The display, as an image.
"""
if self._display_widget is not None:
return utils.widget_to_image(self._display_widget)
[docs] @Slot()
def copy_to_clipboard(self):
"""Copy the display image to the clipboard."""
image = self.to_image()
if image is not None:
clipboard = QtGui.QGuiApplication.clipboard()
clipboard.setImage(image)
@Slot(object)
def _tx(self, value):
"""Receive information from happi channel."""
self.add_device(value['obj'], macros=value['md'])
def __repr__(self):
"""Get a custom representation for TyphosDeviceDisplay."""
return (
f'<{self.__class__.__name__} at {hex(id(self))} '
f'device={self.device_class}[{self.device_name!r}] '
f'nested={self._nested}'
f'>'
)
[docs]def toggle_display(widget, force_state=None):
"""
Toggle the visibility of all :class:`TyphosSignalPanel` in a display.
Parameters
----------
widget : QWidget
The widget in which to look for Panels.
force_state : bool
If set to True or False, it will change visibility to the value of
force_state.
If not set or set to None, it will flip the current panels state.
"""
panels = widget.findChildren(typhos_panel.TyphosSignalPanel) or []
visible = all(panel.isVisible() for panel in panels)
state = not visible
if force_state is not None:
state = force_state
for panel in panels:
panel.setVisible(state)
[docs]def show_empty(widget):
"""
Recursively shows all panels and widgets, empty or not.
Parameters
----------
widget : QWidget
"""
children = widget.findChildren(TyphosDeviceDisplay) or []
for ch in children:
show_empty(ch)
widget.setVisible(True)
toggle_display(widget, force_state=True)
[docs]def hide_empty(widget, process_widget=True):
"""
Recursively hide empty panels and widgets.
Parameters
----------
widget : QWidget
The widget in which to start the recursive search.
process_widget : bool
Whether or not to process the visibility for the widget.
This is useful since we don't want to hide the top-most
widget otherwise users can't change the visibility back on.
"""
def process(item, recursive=True):
if isinstance(item, TyphosDeviceDisplay) and recursive:
hide_empty(item)
elif isinstance(item, typhos_panel.TyphosSignalPanel):
if recursive:
hide_empty(item)
visible = bool(item._panel_layout.visible_elements)
item.setVisible(visible)
if isinstance(widget, TyphosDeviceDisplay):
# Check if the template at this display is one of the defaults
# otherwise we are not sure if we can safely change it.
if widget.current_template not in DEFAULT_TEMPLATES_FLATTEN:
logger.info("Can't hide empty entries in non built-in templates")
return
children = widget.findChildren(utils.TyphosBase) or []
for w in children:
process(w)
if process_widget:
if isinstance(widget, TyphosDeviceDisplay):
overall_status = any(w.isVisible() for w in children)
elif isinstance(widget, typhos_panel.TyphosSignalPanel):
overall_status = bool(widget._panel_layout.visible_elements)
widget.setVisible(overall_status)