class PyDMEnumButton(QWidget, PyDMWritableWidget, WidgetType): """ A QWidget that renders buttons for every option of Enum Items. For now three types of buttons can be rendered: - Push Button - Radio Button Parameters ---------- parent : QWidget The parent widget for the Label init_channel : str, optional The channel to be used by the widget. Signals ------- send_value_signal : int, float, str, bool or np.ndarray Emitted when the user changes the value. """ Q_ENUMS(WidgetType) WidgetType = WidgetType def __init__(self, parent=None, init_channel=None): QWidget.__init__(self, parent) PyDMWritableWidget.__init__(self, init_channel=init_channel) self._has_enums = False self._checkable = True self.setLayout(QGridLayout(self)) self._btn_group = QButtonGroup() self._btn_group.setExclusive(True) self._btn_group.buttonClicked[int].connect(self.handle_button_clicked) self._widget_type = WidgetType.PushButton self._orientation = Qt.Vertical self._widgets = [] self.rebuild_widgets() def minimumSizeHint(self): """ This property holds the recommended minimum size for the widget. Returns ------- QSize """ # This is totally arbitrary, I just want *some* visible nonzero size return QSize(50, 100) @Property("QStringList") def items(self): """ Items to be displayed in the button group. This property can be overridden by the items coming from the control system. Because C++ QStringList expects a list type, we need to make sure that None is never returned. Returns ------- List[str] """ return self.enum_strings or [] @items.setter def items(self, value): self.enum_strings_changed(value) @Property(WidgetType) def widgetType(self): """ The widget type to be used when composing the group. Returns ------- WidgetType """ return self._widget_type @widgetType.setter def widgetType(self, new_type): """ The widget type to be used when composing the group. Parameters ---------- new_type : WidgetType """ if new_type != self._widget_type: self._widget_type = new_type self.rebuild_widgets() @Property(Qt.Orientation) def orientation(self): """ Whether to lay out the bit indicators vertically or horizontally. Returns ------- int """ return self._orientation @orientation.setter def orientation(self, new_orientation): """ Whether to lay out the bit indicators vertically or horizontally. Parameters ------- new_orientation : Qt.Orientation, int """ if new_orientation != self._orientation: self._orientation = new_orientation self.rebuild_layout() @Property(bool) def checkable(self): """ Whether or not the button should be checkable. Returns ------- bool """ return self._checkable @checkable.setter def checkable(self, value): if value != self._checkable: self._checkable = value for widget in self._widgets: widget.setCheckable(value) @Slot(int) def handle_button_clicked(self, id): """ Handles the event of a button being clicked. Parameters ---------- id : int The clicked button id. """ self.send_value_signal.emit(id) def clear(self): """ Remove all inner widgets from the layout """ for col in range(0, self.layout().columnCount()): for row in range(0, self.layout().rowCount()): item = self.layout().itemAtPosition(row, col) if item is not None: w = item.widget() if w is not None: self.layout().removeWidget(w) def rebuild_widgets(self): """ Rebuild the list of widgets based on a new enum or generates a default list of fake strings so we can see something at Designer. """ def generate_widgets(items): while len(self._widgets) != 0: w = self._widgets.pop(0) self._btn_group.removeButton(w) w.deleteLater() for idx, entry in enumerate(items): w = class_for_type[self._widget_type](parent=self) w.setCheckable(self.checkable) w.setText(entry) self._widgets.append(w) self._btn_group.addButton(w, idx) self.clear() if self._has_enums: generate_widgets(self.enum_strings) else: generate_widgets(["Item 1", "Item 2", "Item ..."]) self.rebuild_layout() def rebuild_layout(self): """ Method to reorganize the top-level widget and its contents according to the layout property values. """ self.clear() if self.orientation == Qt.Vertical: for i, widget in enumerate(self._widgets): self.layout().addWidget(widget, i, 0) elif self.orientation == Qt.Horizontal: for i, widget in enumerate(self._widgets): self.layout().addWidget(widget, 0, i) def check_enable_state(self): """ Checks whether or not the widget should be disable. This method also disables the widget and add a Tool Tip with the reason why it is disabled. """ status = self._write_access and self._connected and self._has_enums tooltip = "" if not self._connected: tooltip += "Channel is disconnected." elif not self._write_access: if data_plugins.is_read_only(): tooltip += "Running PyDM on Read-Only mode." else: tooltip += "Access denied by Channel Access Security." elif not self._has_enums: tooltip += "Enums not available." self.setToolTip(tooltip) self.setEnabled(status) def value_changed(self, new_val): """ Callback invoked when the Channel value is changed. Parameters ---------- new_val : int The new value from the channel. """ if new_val is not None and new_val != self.value: super(PyDMEnumButton, self).value_changed(new_val) btn = self._btn_group.button(new_val) if btn: btn.setChecked(True) def enum_strings_changed(self, new_enum_strings): """ Callback invoked when the Channel has new enum values. This callback also triggers a value_changed call so the new enum values to be broadcasted. Parameters ---------- new_enum_strings : tuple The new list of values """ if new_enum_strings is not None \ and new_enum_strings != self.enum_strings: super(PyDMEnumButton, self).enum_strings_changed(new_enum_strings) self._has_enums = True self.check_enable_state() self.rebuild_widgets() def paintEvent(self, _): """ Paint events are sent to widgets that need to update themselves, for instance when part of a widget is exposed because a covering widget was moved. At PyDMDrawing this method handles the alarm painting with parameters from the stylesheet, configures the brush, pen and calls ```draw_item``` so the specifics can be performed for each of the drawing classes. Parameters ---------- event : QPaintEvent """ painter = QPainter(self) opt = QStyleOption() opt.initFrom(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) painter.setRenderHint(QPainter.Antialiasing)
class HalLabel(QLabel, HALWidget, HalType): """HAL Label""" Q_ENUMS(HalType) def __init__(self, parent=None): super(HalLabel, self).__init__(parent) self._in_pin = None self._enable_pin = None self._typ = "float" self._fmt = "%s" self.setValue(0) def setValue(self, value): self.setText(self._fmt % value) @Property(str) def textFormat(self): """Text Format Property Args: fmt (str) : A valid python style format string. Defaults to ``%s``. """ return self._fmt @textFormat.setter def textFormat(self, fmt): self._fmt = fmt try: val = {'bit': False, 'u32': 0, 's32': 0, 'float': 0.0}[self._typ] self.setValue(val) except: pass @Property(HalType) def pinType(self): return getattr(HalType, self._typ) @pinType.setter def pinType(self, typ_enum): self._typ = HalType.toString(typ_enum) try: val = {'bit': False, 'u32': 0, 's32': 0, 'float': 0.0}[self._typ] self.setValue(val) except: pass def initialize(self): comp = hal.COMPONENTS['qtpyvcp'] obj_name = str(self.objectName()).replace('_', '-') # add label.enable HAL pin self._enable_pin = comp.addPin(obj_name + ".enable", "bit", "in") self._enable_pin.value = self.isEnabled() self._enable_pin.valueChanged.connect(self.setEnabled) # add label.in HAL pin self._in_pin = comp.addPin(obj_name + ".in", self._typ, "in") self.setValue(self._in_pin.value) self._in_pin.valueChanged.connect(self.setValue)
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 If ``True``, put the loaded template into a :class:`QScrollArea`. Otherwise, the display widget will go directly in this widget's layout. 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, optional The default display type. 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=None, *, scrollable=True, composite_heuristics=True, embedded_templates=None, detailed_templates=None, engineering_templates=None, display_type='detailed_screen', nested=False): self._composite_heuristics = composite_heuristics self._current_template = None self._forced_template = '' self._macros = {} self._display_widget = None self._scrollable = False 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) self.scrollable = scrollable @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(bool) def scrollable(self): """Place the display in a scrollable area.""" return self._scrollable @scrollable.setter def scrollable(self, scrollable): # Switch between using the scroll area layout or if scrollable == self._scrollable: return self._scrollable = bool(scrollable) 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._scrollable: self._scroll_area.setWidget(widget) else: self.layout().addWidget(widget) self._scroll_area.setVisible(self._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: ... 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 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: 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 widget = self._load_template(self._current_template) 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.""" 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 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. 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) self._searched = False self.macros = self._build_macros_from_device(device, macros=macros) self.load_best_template() 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 ]) @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 @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 @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 ' 'device %s', obj) 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. """ return [ template for template in utils.find_templates_for_class( device_cls, 'detailed', utils.DISPLAY_PATHS) if not utils.is_standard_template(template) ] 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) @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'>')
class PyDMLabel(QLabel, TextFormatter, PyDMWidget, DisplayFormat): Q_ENUMS(DisplayFormat) DisplayFormat = DisplayFormat """ A QLabel 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. """ def __init__(self, parent=None, init_channel=None): QLabel.__init__(self, parent) PyDMWidget.__init__(self, init_channel=init_channel) self.app = QApplication.instance() self.setTextFormat(Qt.PlainText) self.setTextInteractionFlags(Qt.NoTextInteraction) self.setText("PyDMLabel") self._display_format_type = self.DisplayFormat.Default self._string_encoding = "utf_8" if is_pydm_app(): self._string_encoding = self.app.get_string_encoding() @Property(DisplayFormat) def displayFormat(self): return self._display_format_type @displayFormat.setter def displayFormat(self, new_type): if self._display_format_type != new_type: self._display_format_type = new_type # Trigger the update of display format self.value_changed(self.value) def value_changed(self, new_value): """ Callback invoked when the Channel value is changed. Sets the value of new_value accordingly at the Label. Parameters ---------- new_value : str, int, float, bool or np.ndarray The new value from the channel. The type depends on the channel. """ super(PyDMLabel, self).value_changed(new_value) new_value = parse_value_for_display( value=new_value, precision=self._prec, display_format_type=self._display_format_type, string_encoding=self._string_encoding, widget=self) # If the value is a string, just display it as-is, no formatting # needed. if isinstance(new_value, str): self.setText(new_value) return # If the value is an enum, display the appropriate enum string for # the value. if self.enum_strings is not None and isinstance(new_value, int): try: self.setText(self.enum_strings[new_value]) except IndexError: self.setText("**INVALID**") return # If the value is a number (float or int), display it using a # format string if necessary. if isinstance(new_value, (int, float)): self.setText(self.format_string.format(new_value)) return # If you made it this far, just turn whatever the heck the value # is into a string and display it. self.setText(str(new_value))
class TyphonSignalPanel(TyphonBase, TyphonDesignerMixin, SignalOrder): """ Panel of Signals for Device """ Q_ENUMS(SignalOrder) # Necessary for display in Designer SignalOrder = SignalOrder # For convenience # From top of page to bottom kind_order = (Kind.hinted, Kind.normal, Kind.config, Kind.omitted) def __init__(self, parent=None, init_channel=None): super().__init__(parent=parent) # Create a SignalPanel layout to be modified later self.setLayout(SignalPanel()) # Add default Kind values self._kinds = dict.fromkeys([kind.name for kind in Kind], True) self._signal_order = SignalOrder.byKind def _get_kind(self, kind): return self._kinds[kind] def _set_kind(self, value, kind): # If we have a new value store it if value != self._kinds[kind]: # Store it internally self._kinds[kind] = value # Remodify the layout for the new Kind self._set_layout() # Kind Configuration pyqtProperty showHints = Property(bool, partial(_get_kind, kind='hinted'), partial(_set_kind, kind='hinted')) showNormal = Property(bool, partial(_get_kind, kind='normal'), partial(_set_kind, kind='normal')) showConfig = Property(bool, partial(_get_kind, kind='config'), partial(_set_kind, kind='config')) showOmitted = Property(bool, partial(_get_kind, kind='omitted'), partial(_set_kind, kind='omitted')) @Property(SignalOrder) def sortBy(self): """Order signals will be placed in layout""" return self._signal_order @sortBy.setter def sortBy(self, value): if value != self._signal_order: self._signal_order = value self._set_layout() def add_device(self, device): """Add a device to the widget""" # Only allow a single device self.devices.clear() # Add the new device super().add_device(device) # Configure the layout for the new device self._set_layout() def _set_layout(self): """Set the layout based on the current device and kind""" # We can't set a layout if we don't have any devices if not self.devices: return # Clear our layout self.layout().clear() shown_kind = [kind for kind in Kind if self._kinds[kind.name]] # Iterate through kinds signals = list() for kind in Kind: if kind in shown_kind: try: for (attr, signal) in grab_kind(self.devices[0], kind.name): label = clean_attr(attr) # Check twice for Kind as signal might have multiple # kinds if signal.kind in shown_kind: signals.append((label, signal)) except Exception: logger.exception("Unable to add %s signals from %r", kind.name, self.devices[0]) # Pick our sorting function if self._signal_order == SignalOrder.byKind: # Sort by kind def sorter(x): return self.kind_order.index(x[1].kind) elif self._signal_order == SignalOrder.byName: # Sort by name def sorter(x): return x[0] else: logger.exception("Unknown sorting type %r", self.sortBy) return # Add to layout for (label, signal) in sorted(set(signals), key=sorter): self.layout().add_signal(signal, label) def sizeHint(self): """Default SizeHint""" return QSize(240, 140)
class RequirementsModel(QAbstractItemModel, RequirementsModelRoles): Q_ENUMS(RequirementsModelRoles) requirementsChanged = Signal() def __init__(self, parent=None): super(RequirementsModel, self).__init__(parent) self._requirements = OrderedDict() self.requirementsChanged.connect(self.modelReset) @Property(OrderedDict, notify=requirementsChanged) def requirements(self): return self._requirements @requirements.setter def requirements(self, value): if value is self._requirements: return self._requirements = value self.requirementsChanged.emit() def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return None key = list(self._requirements.keys())[index.row()] switch = { Qt.DisplayRole: lambda: key, self.KeyRole: lambda: key, self.ValueRole: lambda: self._requirements[key], } data = switch.get(role, lambda: None)() return data def roleNames(self): return self.role_names() def index(self, row, column, parent=QModelIndex()): if not self.hasIndex(row, column, parent): return QModelIndex() return self.createIndex(row, column) def flags(self, index): if not index.isValid(): return Qt.NoItemFlags return Qt.ItemIsEnabled | Qt.ItemIsSelectable def headerData(self, _section, orientation, role=Qt.DisplayRole): if not orientation == Qt.Horizontal: return None return self.role_names().get(role, '').title() def parent(self, index): return QModelIndex() def columnCount(self, _parent=QModelIndex()): return len(self.role_names()) def rowCount(self, parent=QModelIndex()): if parent.isValid(): return 0 return len(self._requirements)
class TyphosSignalPanel(TyphosBase, TyphosDesignerMixin, SignalOrder): """ Panel of Signals for a given device, using :class:`SignalPanel`. Parameters ---------- parent : QtWidgets.QWidget, optional The parent widget. init_channel : str, optional The PyDM channel with which to initialize the widget. """ Q_ENUMS(SignalOrder) # Necessary for display in Designer SignalOrder = SignalOrder # For convenience # From top of page to bottom kind_order = (Kind.hinted, Kind.normal, Kind.config, Kind.omitted) _panel_class = SignalPanel updated = QtCore.Signal() _kind_to_property = { 'hinted': 'showHints', 'normal': 'showNormal', 'config': 'showConfig', 'omitted': 'showOmitted', } def __init__(self, parent=None, init_channel=None): super().__init__(parent=parent) # Create a SignalPanel layout to be modified later self._panel_layout = self._panel_class() self.setLayout(self._panel_layout) self._name_filter = '' # Add default Kind values self._kinds = dict.fromkeys([kind.name for kind in Kind], True) self._signal_order = SignalOrder.byKind self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) self.contextMenuEvent = self.open_context_menu def _get_kind(self, kind): """Property getter for show[kind].""" return self._kinds[kind] def _set_kind(self, value, kind): """Property setter for show[kind] = value.""" # If we have a new value store it if value != self._kinds[kind]: # Store it internally self._kinds[kind] = value # Remodify the layout for the new Kind self._update_panel() @property def filter_settings(self): """Get the filter settings dictionary.""" return dict( name_filter=self.nameFilter, kinds=self.show_kinds, ) def _update_panel(self): """Apply filters and emit the update signal.""" self._panel_layout.filter_signals(**self.filter_settings) self.updated.emit() @property def show_kinds(self): """Get a list of the :class:`ophyd.Kind` that should be shown.""" return [kind for kind in Kind if self._kinds[kind.name]] # Kind Configuration pyqtProperty showHints = Property(bool, partial(_get_kind, kind='hinted'), partial(_set_kind, kind='hinted'), doc='Show ophyd.Kind.hinted signals') showNormal = Property(bool, partial(_get_kind, kind='normal'), partial(_set_kind, kind='normal'), doc='Show ophyd.Kind.normal signals') showConfig = Property(bool, partial(_get_kind, kind='config'), partial(_set_kind, kind='config'), doc='Show ophyd.Kind.config signals') showOmitted = Property(bool, partial(_get_kind, kind='omitted'), partial(_set_kind, kind='omitted'), doc='Show ophyd.Kind.omitted signals') @Property(str) def nameFilter(self): """Get or set the current name filter.""" return self._name_filter @nameFilter.setter def nameFilter(self, name_filter): if name_filter != self._name_filter: self._name_filter = name_filter.strip() self._update_panel() @Property(SignalOrder) def sortBy(self): """Get or set the order that the signals will be placed in layout.""" return self._signal_order @sortBy.setter def sortBy(self, value): if value != self._signal_order: self._signal_order = value self._update_panel() def add_device(self, device): """Typhos hook for adding a new device.""" self.devices.clear() super().add_device(device) # Configure the layout for the new device self._panel_layout.add_device(device) self._update_panel() def set_device_display(self, display): """Typhos hook for when the TyphosDeviceDisplay is associated.""" self.display = display def generate_context_menu(self): """Generate a context menu for this TyphosSignalPanel.""" menu = QtWidgets.QMenu(parent=self) menu.addSection('Kinds') for kind, property_name in self._kind_to_property.items(): def selected(new_value, *, name=property_name): setattr(self, name, new_value) action = menu.addAction('Show &' + kind) action.setCheckable(True) action.setChecked(getattr(self, property_name)) action.triggered.connect(selected) return menu def open_context_menu(self, ev): """ Open a context menu when the Default Context Menu is requested. Parameters ---------- ev : QEvent """ menu = self.generate_context_menu() menu.exec_(self.mapToGlobal(ev.pos()))
class WatchlistModel(QAbstractItemModel, WatchlistModelRoles): Q_ENUMS(WatchlistModelRoles) appInterfaceChanged = Signal() def __init__(self, parent=None): super(WatchlistModel, self).__init__(parent) self._app_interface = None @Property(AppInterface, notify=appInterfaceChanged) def appInterface(self): return self._app_interface @appInterface.setter def appInterface(self, new_interface): if new_interface == self._app_interface: return old_interface = self._app_interface self._app_interface = new_interface self.appInterfaceChanged.emit() if old_interface: old_interface.watchedChanged.disconnect(self.modelReset) old_interface.itemUpdated.disconnect(self._on_item_updated) if new_interface: new_interface.watchedChanged.connect(self.modelReset) new_interface.itemUpdated.connect(self._on_item_updated) self.modelReset.emit() @property def _watched(self): if self._app_interface: return self._app_interface.watched else: return [] def _on_item_updated(self, key): for i, item in enumerate(self._watched): if item.key == key: index = self.index(i, 0) self.dataChanged.emit(index, index) return def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return None watched = self._watched row = index.row() if row >= len(watched): return None item = watched[row] switch = { Qt.DisplayRole: lambda: item.key, self.KeyRole: lambda: item.key, self.ValueRole: lambda: value_to_str(item.value), } data = switch.get(role, lambda: None)() return data def setData(self, index, value, role=Qt.EditRole): item = index.internalPointer() if role == self.ValueRole: new_value = str_to_value(value, item.value) if new_value is None: logger.error(f'Cannot convert value {item.key} {value}') changed = False else: item.value = new_value self._app_interface.setValue(item.key, new_value) changed = True else: changed = False if changed: self.dataChanged.emit(index, index, [role]) return changed def roleNames(self): return self.role_names() def index(self, row, column, parent=QModelIndex()): if not self.hasIndex(row, column, parent=QModelIndex()): return QModelIndex() item = self._app_interface.watched[row] return self.createIndex(row, column, item) def flags(self, index): if not index.isValid(): return Qt.NoItemFlags return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable def parent(self, index): return QModelIndex() def columnCount(self, _parent=QModelIndex()): return len(self.role_names()) def rowCount(self, parent=QModelIndex()): if parent.isValid(): return 0 return len(self._app_interface.watched)
class PyDMStateButton(QFrame, PyDMWritableWidget): """ A StateButton with support for Channels and much more from PyDM. It consists on QPushButton with internal state. Parameters ---------- parent : QWidget The parent widget for the Label init_channel : str, optional The channel to be used by the widget. """ class buttonShapeMap: """Enum class of shapes of button.""" locals().update(**BUTTONSHAPE) Q_ENUMS(buttonShapeMap) # enumMap for buttonShapeMap locals().update(**BUTTONSHAPE) squaredbuttonstatesdict = dict() path = _os.path.abspath(_os.path.dirname(__file__)) f = QFile(_os.path.join(path, 'resources/but_shapes/squared_on.svg')) if f.open(QFile.ReadOnly): squaredbuttonstatesdict['On'] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(path, 'resources/but_shapes/squared_off.svg')) if f.open(QFile.ReadOnly): squaredbuttonstatesdict['Off'] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(path, 'resources/but_shapes/squared_disconn.svg')) if f.open(QFile.ReadOnly): squaredbuttonstatesdict['Disconnected'] = str(f.readAll(), 'utf-8') f.close() roundedbuttonstatesdict = dict() f = QFile(_os.path.join(path, 'resources/but_shapes/rounded_on.svg')) if f.open(QFile.ReadOnly): roundedbuttonstatesdict['On'] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(path, 'resources/but_shapes/rounded_off.svg')) if f.open(QFile.ReadOnly): roundedbuttonstatesdict['Off'] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(path, 'resources/but_shapes/rounded_disconn.svg')) if f.open(QFile.ReadOnly): roundedbuttonstatesdict['Disconnected'] = str(f.readAll(), 'utf-8') f.close() clicked = Signal() DEFAULT_CONFIRM_MESSAGE = "Are you sure you want to proceed?" def __init__(self, parent=None, init_channel=None, invert=False): """Initialize all internal states and properties.""" QFrame.__init__(self, parent) PyDMWritableWidget.__init__(self, init_channel=init_channel) self._off = 0 self._on = 1 self.invert = invert self._bit = -1 self._bit_val = 0 self.value = 0 self.clicked.connect(self.send_value) self.shape = 0 self.renderer = QSvgRenderer() self._show_confirm_dialog = False self._confirm_message = PyDMStateButton.DEFAULT_CONFIRM_MESSAGE self._password_protected = False self._password = "" self._protected_password = "" @Property(bool) def passwordProtected(self): """ Whether or not this button is password protected. Returns ------- bool """ return self._password_protected @passwordProtected.setter def passwordProtected(self, value): """ Whether or not this button is password protected. Parameters ---------- value : bool """ if self._password_protected != value: self._password_protected = value @Property(str) def password(self): """ Password to be encrypted using SHA256. .. warning:: To avoid issues exposing the password this method always returns an empty string. Returns ------- str """ return "" @password.setter def password(self, value): """ Password to be encrypted using SHA256. Parameters ---------- value : str The password to be encrypted """ if value is not None and value != "": sha = _hashlib.sha256() sha.update(value.encode()) # Use the setter as it also checks whether the existing password # is the same with the new one, and only updates if the new # password is different self.protectedPassword = sha.hexdigest() @Property(str) def protectedPassword(self): """ The encrypted password. Returns ------- str """ return self._protected_password @protectedPassword.setter def protectedPassword(self, value): if self._protected_password != value: self._protected_password = value @Property(bool) def showConfirmDialog(self): """ Wether or not to display a confirmation dialog. Returns ------- bool """ return self._show_confirm_dialog @showConfirmDialog.setter def showConfirmDialog(self, value): """ Wether or not to display a confirmation dialog. Parameters ---------- value : bool """ if self._show_confirm_dialog != value: self._show_confirm_dialog = value @Property(str) def confirmMessage(self): """ Message to be displayed at the Confirmation dialog. Returns ------- str """ return self._confirm_message @confirmMessage.setter def confirmMessage(self, value): """ Message to be displayed at the Confirmation dialog. Parameters ---------- value : str """ if self._confirm_message != value: self._confirm_message = value def mouseReleaseEvent(self, ev): """Deal with mouse clicks. Only accept clicks within the figure.""" cond = ev.button() == Qt.LeftButton cond &= ev.x() < self.width()/2+self.height() cond &= ev.x() > self.width()/2-self.height() if cond: self.clicked.emit() def value_changed(self, new_val): """ Callback invoked when the Channel value is changed. Display the value of new_val accordingly. If :attr:'pvBit' is n>=0 or positive the button display the state of the n-th digit of the channel. Parameters ---------- new_value : str, int, float, bool or np.ndarray The new value from the channel. The type depends on the channel. """ if isinstance(new_val, _np.ndarray): _log.warning('PyDMStateButton received a numpy array to ' + self.channel+' ('+str(new_val)+')!') return super(PyDMStateButton, self).value_changed(new_val) value = int(new_val) self.value = value if self._bit >= 0: value = (value >> self._bit) & 1 self._bit_val = value self.update() def confirm_dialog(self): """ Show the confirmation dialog with the proper message in case ```showConfirmMessage``` is True. Returns ------- bool True if the message was confirmed or if ```showCofirmMessage``` is False. """ if not self._show_confirm_dialog: return True if self._confirm_message == "": self._confirm_message = PyDMStateButton.DEFAULT_CONFIRM_MESSAGE msg = QMessageBox() msg.setIcon(QMessageBox.Question) msg.setText(self._confirm_message) msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msg.setDefaultButton(QMessageBox.No) ret = msg.exec_() return not ret == QMessageBox.No def validate_password(self): """ If the widget is ```passwordProtected```, this method will propmt the user for the correct password. Returns ------- bool True in case the password was correct of if the widget is not password protected. """ if not self._password_protected: return True pwd, ok = QInputDialog().getText( None, "Authentication", "Please enter your password:"******"") pwd = str(pwd) if not ok or pwd == "": return False sha = _hashlib.sha256() sha.update(pwd.encode()) pwd_encrypted = sha.hexdigest() if pwd_encrypted != self._protected_password: msg = QMessageBox() msg.setIcon(QMessageBox.Critical) msg.setText("Invalid password.") msg.setWindowTitle("Error") msg.setStandardButtons(QMessageBox.Ok) msg.setDefaultButton(QMessageBox.Ok) msg.setEscapeButton(QMessageBox.Ok) msg.exec_() return False return True def send_value(self): """ Emit a :attr:`send_value_signal` to update channel value. If :attr:'pvBit' is n>=0 or positive the button toggles the state of the n-th digit of the channel. Otherwise it toggles the whole value. """ if not self._connected: return None if not self.confirm_dialog(): return None if not self.validate_password(): return None checked = not self._bit_val val = checked if self._bit >= 0: val = int(self.value) val ^= (-checked ^ val) & (1 << self._bit) # For explanation look: # https://stackoverflow.com/questions/47981/how-do-you-set-clear-and-toggle-a-single-bit self.send_value_signal[self.channeltype].emit(self.channeltype(val)) def sizeHint(self): """Return size hint to define size on initialization.""" return QSize(72, 36) def paintEvent(self, event): """Treat appearence changes based on connection state and value.""" self.style().unpolish(self) self.style().polish(self) if not self.isEnabled(): state = 'Disconnected' elif self._bit_val == self._on: state = 'On' elif self._bit_val == self._off: state = 'Off' else: state = 'Disconnected' if self.shape == 0: shape_dict = PyDMStateButton.squaredbuttonstatesdict elif self.shape == 1: shape_dict = PyDMStateButton.roundedbuttonstatesdict option = QStyleOption() option.initFrom(self) h = option.rect.height() w = option.rect.width() aspect = 2.0 ah = w/aspect aw = w if ah > h: ah = h aw = h*aspect x = abs(aw-w)/2.0 y = abs(ah-h)/2.0 bounds = QRectF(x, y, aw, ah) painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing, True) shape_str = shape_dict[state] buttonstate_bytearray = bytes(shape_str, 'utf-8') self.renderer.load(QByteArray(buttonstate_bytearray)) self.renderer.render(painter, bounds) @Property(buttonShapeMap) def shape(self): """ Property to define the shape of the button. Returns ------- int """ return self._shape @shape.setter def shape(self, new_shape): """ Property to define the shape of the button. Parameters ---------- value : int """ if new_shape in [PyDMStateButton.Rounded, PyDMStateButton.Squared]: self._shape = new_shape self.update() else: raise ValueError('Button shape not defined!') @Property(int) def pvbit(self): """ Property to define which PV bit to control. Returns ------- int """ return int(self._bit) @pvbit.setter def pvbit(self, bit): """ Property to define which PV bit to control. Parameters ---------- value : int """ if bit >= 0: self._bit = int(bit) @Property(bool) def invert(self): """ Property that indicates whether to invert button on/off representation. Return ------ bool """ return self._invert @invert.setter def invert(self, value): """ Property that indicates whether to invert button on/off representation. Parameters ---------- value: bool """ self._invert = value if self._invert: self._on = 0 self._off = 1 else: self._on = 1 self._off = 0
class PyDMTemplateRepeater(QFrame, PyDMPrimitiveWidget, LayoutType): """ PyDMTemplateRepeater takes a .ui file with macro variables as a template, and a JSON file (or a list of dictionaries) with a list of values to use to fill in the macro variables, then creates a layout with one instance of the template for each item in the list. It can be very convenient if you have displays that repeat the same set of widgets over and over - for instance, if you have a standard set of controls for a magnet, and want to build a display with a list of controls for every magnet, the Template Repeater lets you do that with a minimum amount of work: just build a template for a single magnet, and a JSON list with the data that describes all of the magnets. Parameters ---------- parent : optional The parent of this widget. """ Q_ENUMS(LayoutType) LayoutType = LayoutType def __init__(self, parent=None): QFrame.__init__(self, parent) PyDMPrimitiveWidget.__init__(self) self._template_filename = "" self._count_shown_in_designer = 1 self._data_source = "" self._data = [] self._cached_template = None self._parent_macros = None self._layout_type = LayoutType.Vertical self._temp_layout_spacing = 4 self.app = QApplication.instance() self.rebuild() @Property(LayoutType) def layoutType(self): """ The layout type to use. Returns ------- LayoutType """ return self._layout_type @layoutType.setter def layoutType(self, new_type): """ The layout type to use. Options are: - **Vertical**: Instances of the template are laid out vertically, in rows. - **Horizontal**: Instances of the template are laid out horizontally, in columns. - **Flow**: Instances of the template are laid out horizontally until they reach the edge of the template, at which point they "wrap" into a new row. Parameters ---------- new_type : LayoutType """ if new_type != self._layout_type: self._layout_type = new_type self.rebuild() @Property(int) def layoutSpacing(self): if self.layout(): return self.layout().spacing() return self._temp_layout_spacing @layoutSpacing.setter def layoutSpacing(self, new_spacing): self._temp_layout_spacing = new_spacing if self.layout(): self.layout().setSpacing(new_spacing) @Property(int) def countShownInDesigner(self): """ The number of instances to show in Qt Designer. This property has no effect outside of Designer. Returns ------- int """ return self._count_shown_in_designer @countShownInDesigner.setter def countShownInDesigner(self, new_count): """ The number of instances to show in Qt Designer. This property has no effect outside of Designer. Parameters ---------- new_count : int """ if not is_qt_designer(): return try: new_count = int(new_count) except ValueError: logger.exception( "Couldn't convert {} to integer.".format(new_count)) return new_count = max(new_count, 0) if new_count != self._count_shown_in_designer: self._count_shown_in_designer = new_count self.rebuild() @Property(str) def templateFilename(self): """ The path to the .ui file to use as a template. Returns ------- str """ return self._template_filename @templateFilename.setter def templateFilename(self, new_filename): """ The path to the .ui file to use as a template. Parameters ---------- new_filename : str """ if new_filename != self._template_filename: self._template_filename = new_filename self._cached_template = None if self._template_filename: self.rebuild() else: self.clear() @Property(str) def dataSource(self): """ The path to the JSON file to fill in each instance of the template. Returns ------- str """ return self._data_source @dataSource.setter def dataSource(self, data_source): """ Sets the path to the JSON file to fill in each instance of the template. For example, if you build a template that contains two macro variables, ${NAME} and ${UNIT}, your JSON file should be a list of dictionaries, each with keys for NAME and UNIT, like this: [{"NAME": "First Device", "UNIT": 1}, {"NAME": "Second Device", "UNIT": 2}] Parameters ------- data_source : str """ if data_source != self._data_source: self._data_source = data_source if self._data_source: try: parent_display = self.find_parent_display() base_path = None if parent_display: base_path = os.path.dirname( parent_display.loaded_file()) fname = find_file(self._data_source, base_path=base_path) if not fname: if not is_qt_designer(): logger.error( 'Cannot locate data source file {} for PyDMTemplateRepeater.' .format(fname)) self.data = [] else: with open(fname) as f: try: self.data = json.load(f) except ValueError: logger.error( 'Failed to parse data source file {} for PyDMTemplateRepeater.' .format(fname)) self.data = [] except IOError as e: self.data = [] else: self.clear() def open_template_file(self, variables=None): """ Opens the widget specified in the templateFilename property. Parameters ---------- variables : dict A dictionary of macro variables to apply when loading, in addition to all the macros specified on the template repeater widget. Returns ------- display : QWidget """ if not variables: variables = {} parent_display = self.find_parent_display() base_path = None if parent_display: base_path = os.path.dirname(parent_display.loaded_file()) fname = find_file(self.templateFilename, base_path=base_path) if self._parent_macros is None: self._parent_macros = {} if parent_display: self._parent_macros = parent_display.macros() parent_macros = copy.copy(self._parent_macros) parent_macros.update(variables) try: w = load_file(fname, macros=parent_macros, target=None) except Exception as ex: w = QLabel('Error: could not load template: ' + str(ex)) return w def rebuild(self): """ Clear out all existing widgets, and populate the list using the template file and data source.""" self.clear() if (not self.templateFilename) or (not self.data): return self.setUpdatesEnabled(False) layout_class = layout_class_for_type[self.layoutType] if type(self.layout()) != layout_class: if self.layout() is not None: # Trick to remove the existing layout by re-parenting it in an empty widget. QWidget().setLayout(self.layout()) l = layout_class(self) self.setLayout(l) self.layout().setSpacing(self._temp_layout_spacing) try: with pydm.data_plugins.connection_queue(defer_connections=True): for i, variables in enumerate(self.data): if is_qt_designer() and i > self.countShownInDesigner - 1: break w = self.open_template_file(variables) if w is None: w = QLabel() w.setText( "No Template Loaded. Data: {}".format(variables)) w.setParent(self) self.layout().addWidget(w) except: logger.exception('Template repeater failed to rebuild.') finally: # If issues happen during the rebuild we should still enable # updates and establish connection for the widgets added. # Moreover, if we dont call establish_queued_connections # the queue will never be emptied and new connections will be # staled. self.setUpdatesEnabled(True) pydm.data_plugins.establish_queued_connections() def clear(self): """ Clear out any existing instances of the template inside the widget.""" if not self.layout(): return while self.layout().count() > 0: item = self.layout().takeAt(0) item.widget().deleteLater() del item def count(self): if not self.layout(): return 0 return self.layout().count() @property def data(self): """ The dictionary used by the widget to fill in each instance of the template. This property will be overwritten if the user changes the dataSource property. """ return self._data @data.setter def data(self, new_data): """ Sets the dictionary used by the widget to fill in each instance of the template. This property will be overwritten if the user changes the dataSource property. After setting this property, `rebuild` is automatically called to refresh the widget. """ self._data = new_data self.rebuild()
class HalLabel(QLabel, HALWidget, HalType): """HAL Label Label for displaying HAL pin values. Input pin type is selectable via the :class:`.pinType` property in designer, and can be any valid HAL type (bit, u32, s32, float). The text format can be specified via the :class:`.textFormat` property in designer and can be any valid python style format string. .. table:: Generated HAL Pins ========================= =========== ========= HAL Pin Name Type Direction ========================= =========== ========= qtpyvcp.label.enable bit in qtpyvcp.label.in selecatable in ========================= =========== ========= """ Q_ENUMS(HalType) def __init__(self, parent=None): super(HalLabel, self).__init__(parent) self._in_pin = None self._enable_pin = None self._typ = "float" self._fmt = "%s" self.setValue(0) def setValue(self, value): self.setText(self._fmt % value) @Property(str) def textFormat(self): """Text Format Property Args: fmt (str) : A valid python style format string. Defaults to ``%s``. """ return self._fmt @textFormat.setter def textFormat(self, fmt): self._fmt = fmt try: val = {'bit': False, 'u32': 0, 's32': 0, 'float': 0.0}[self._typ] self.setValue(val) except: pass @Property(HalType) def pinType(self): return getattr(HalType, self._typ) @pinType.setter def pinType(self, typ_enum): self._typ = HalType.toString(typ_enum) try: val = {'bit': False, 'u32': 0, 's32': 0, 'float': 0.0}[self._typ] self.setValue(val) except: pass def initialize(self): comp = hal.getComponent() obj_name = self.getPinBaseName() # add label.enable HAL pin self._enable_pin = comp.addPin(obj_name + ".enable", "bit", "in") self._enable_pin.value = self.isEnabled() self._enable_pin.valueChanged.connect(self.setEnabled) # add label.in HAL pin self._in_pin = comp.addPin(obj_name + ".in", self._typ, "in") self.setValue(self._in_pin.value) self._in_pin.valueChanged.connect(self.setValue)
class QLed(QFrame, ShapeMap): """QLed class.""" ShapeMap = ShapeMap Q_ENUMS(ShapeMap) abspath = _os.path.abspath(_os.path.dirname(__file__)) shapesdict = dict() f = QFile(_os.path.join(abspath, 'resources/led_shapes/circle.svg')) if f.open(QFile.ReadOnly): shapesdict[ShapeMap.Circle] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(abspath, 'resources/led_shapes/round.svg')) if f.open(QFile.ReadOnly): shapesdict[ShapeMap.Round] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(abspath, 'resources/led_shapes/square.svg')) if f.open(QFile.ReadOnly): shapesdict[ShapeMap.Square] = str(f.readAll(), 'utf-8') f.close() f = QFile(_os.path.join(abspath, 'resources/led_shapes/triangle.svg')) if f.open(QFile.ReadOnly): shapesdict[ShapeMap.Triangle] = str(f.readAll(), 'utf-8') f.close() Green = QColor(15, 105, 0) Red = QColor(207, 0, 0) Gray = QColor(90, 90, 90) SelColor = QColor(0, 0, 0) NotSelColor1 = QColor(251, 244, 252) NotSelColor2 = QColor(173, 173, 173) clicked = Signal() selected = Signal(bool) def __init__(self, parent=None, **kwargs): """Class constructor.""" super().__init__(parent, **kwargs) self.m_state = 0 self.m_stateColors = [self.Red, self.Green] self.m_dsblColor = self.Gray self.m_shape = self.ShapeMap.Circle self._pressed = False self._isselected = False self.renderer = QSvgRenderer() def getState(self): """Value property getter.""" return self.m_state def setState(self, value): """Value property setter.""" self.m_state = value self.update() state = Property(bool, getState, setState) def getOnColor(self): """On color property getter.""" return self.m_stateColors[1] def setOnColor(self, newColor): """On color property setter.""" self.m_stateColors[1] = newColor self.update() onColor = Property(QColor, getOnColor, setOnColor) def getOffColor(self): """Off color property getter.""" return self.m_stateColors[0] def setOffColor(self, newColor): """Off color property setter.""" self.m_stateColors[0] = newColor self.update() offColor = Property(QColor, getOffColor, setOffColor) @property def stateColors(self): """Color list property getter.""" return list(self.m_stateColors) @stateColors.setter def stateColors(self, new_colors): """Color list property setter.""" if not isinstance(new_colors, (list, tuple)) or\ len(new_colors) < 2 or not isinstance(new_colors[0], QColor): return self.m_stateColors = list(new_colors) def getDsblColor(self): """Disabled color property getter.""" return self.m_dsblColor def setDsblColor(self, newColor): """Disabled color property setter.""" self.m_dsblColor = newColor self.update() dsblColor = Property(QColor, getDsblColor, setDsblColor) def getShape(self): """Shape property getter.""" return self.m_shape def setShape(self, newShape): """Shape property setter.""" self.m_shape = newShape self.update() shape = Property(ShapeMap, getShape, setShape) def sizeHint(self): """Return the base size of the widget according to shape.""" if self.m_shape == self.ShapeMap.Triangle: return QSize(48, 36) elif self.m_shape == self.ShapeMap.Round: return QSize(72, 36) return QSize(36, 36) def adjust(self, r, g, b): """Adjust the color to set on svg code.""" def normalise(x): return x / 255.0 def denormalise(x): if x <= 1: return int(x * 255.0) else: return 255.0 (h, l, s) = rgb_to_hls(normalise(r), normalise(g), normalise(b)) (nr, ng, nb) = hls_to_rgb(h, l * 1.5, s) return (denormalise(nr), denormalise(ng), denormalise(nb)) def getRGBfromQColor(self, qcolor): """Convert QColors to a tupple of rgb colors to set on svg code.""" redhex = qcolor.red() greenhex = qcolor.green() bluehex = qcolor.blue() return (redhex, greenhex, bluehex) def paintEvent(self, event): """Handle appearence of the widget on state updates.""" self.style().unpolish(self) self.style().polish(self) option = QStyleOption() option.initFrom(self) h = option.rect.height() w = option.rect.width() if self.m_shape in (self.ShapeMap.Triangle, self.ShapeMap.Round): aspect = (4 / 3.0) if self.m_shape == self.ShapeMap.Triangle else 2.0 ah = w / aspect aw = w if ah > h: ah = h aw = h * aspect x = abs(aw - w) / 2.0 y = abs(ah - h) / 2.0 bounds = QRectF(x, y, aw, ah) else: size = min(w, h) x = abs(size - w) / 2.0 y = abs(size - h) / 2.0 bounds = QRectF(x, y, size, size) painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing, True) ind = self.m_state % len(self.m_stateColors) dark_r, dark_g, dark_b = self.getRGBfromQColor(self.m_stateColors[ind]) if not self.isEnabled(): dark_r, dark_g, dark_b = self.getRGBfromQColor(self.m_dsblColor) sel1_r, sel1_g, sel1_b = self.getRGBfromQColor(self.SelColor) sel2_r, sel2_g, sel2_b = self.getRGBfromQColor(self.SelColor) opc = '1.000' if not self.isSelected(): sel1_r, sel1_g, sel1_b = self.getRGBfromQColor(self.NotSelColor1) sel2_r, sel2_g, sel2_b = self.getRGBfromQColor(self.NotSelColor2) opc = '0.145' dark_str = "rgb(%d,%d,%d)" % (dark_r, dark_g, dark_b) light_str = "rgb(%d,%d,%d)" % self.adjust(dark_r, dark_g, dark_b) sel1_str = "rgb(%d,%d,%d)" % (sel1_r, sel1_g, sel1_b) sel2_str = "rgb(%d,%d,%d)" % (sel2_r, sel2_g, sel2_b) shape_bytes = bytes( self.shapesdict[self.m_shape] % (sel1_str, opc, sel2_str, dark_str, light_str), 'utf-8') self.renderer.load(QByteArray(shape_bytes)) self.renderer.render(painter, bounds) def mousePressEvent(self, event): """Handle mouse press event.""" self._pressed = True super().mousePressEvent(event) def mouseReleaseEvent(self, event): """Handle mouse release event.""" if self._pressed: self._pressed = False self.clicked.emit() super().mouseReleaseEvent(event) def toggleState(self): """Toggle state property.""" self.m_state = 0 if self.m_state else 1 self.update() def isSelected(self): """Return selected state of object.""" return self._isselected def setSelected(self, sel): """Configure selected state of object.""" self._isselected = bool(sel) self.selected.emit(self._isselected) self.update() def toggleSelected(self): """Toggle isSelected property.""" self.setSelected(not self.isSelected())
class PyDMLogLabel(QListWidget, TextFormatter, PyDMWidget, DisplayFormat): """ A QListWidget 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. """ DisplayFormat = DisplayFormat Q_ENUMS(DisplayFormat) errorcolor = QColor(255, 0, 0) warncolor = QColor(200, 200, 0) def __init__(self, parent=None, init_channel=None, replace=None): QListWidget.__init__(self, parent) PyDMWidget.__init__(self, init_channel=init_channel) self._buffer_size = 1000 self._prepend_date_time = True self._display_format_type = DisplayFormat.String self._string_encoding = "utf_8" self._date_time_fmt = '%Y/%m/%d-%H:%M:%S' self._replace = list() if replace is None else replace channel = '' if init_channel is None else init_channel self._plugin_conns = plugin_for_address(channel).connections def value_changed(self, new_value): """ Callback invoked when the Channel value is changed. Sets the value of new_value accordingly at the Label. Parameters ---------- new_value : str, int, float, bool or np.ndarray The new value from the channel. The type depends on the channel. """ super(PyDMLogLabel, self).value_changed(new_value) new_value = parse_value_for_display( value=new_value, precision=self._prec, display_format_type=self._display_format_type, string_encoding=self._string_encoding, widget=self) if self.count() > self._buffer_size: self.clear() prefix = '' if self._prepend_date_time: timestamp = self._plugin_conns[self.channel].pv.timestamp prefix += _Time(timestamp).strftime(self._date_time_fmt) prefix += ' ' # If the value is a string, just display it as-is, no formatting # needed. item = None if isinstance(new_value, str): if self._replace: last_item = self.item(self.count() - 1) if last_item is not None: last_text = last_item.text().lower() for r in self._replace: if r.lower() in new_value.lower() and \ r.lower() in last_text.lower(): item = last_item item.setText(prefix + new_value) return if item is None: item = QListWidgetItem(prefix + new_value) if new_value.lower().startswith(('err', 'fatal')): item.setForeground(self.errorcolor) elif new_value.lower().startswith('warn'): item.setForeground(self.warncolor) # If the value is an enum, display the appropriate enum string for # the value. elif self.enum_strings is not None and isinstance(new_value, int): try: item = QListWidgetItem(prefix + self.enum_strings[new_value]) except IndexError: item = QListWidgetItem("**INVALID**") # If the value is a number (float or int), display it using a # format string if necessary. elif isinstance(new_value, (int, float)): item = QListWidgetItem(prefix + self.format_string.format(new_value)) # If you made it this far, just turn whatever the heck the value # is into a string and display it. else: item = QListWidgetItem(prefix + str(new_value)) if item is not None: self.addItem(item) self.scrollToBottom() @Property(DisplayFormat) def displayFormat(self): """ The format to display data. Returns ------- int """ return self._display_format_type @displayFormat.setter def displayFormat(self, new_type): """ The maximum number of entries to show. When maximum is exceeded the widget is cleared. Returns ------- int """ if self._display_format_type != new_type: self._display_format_type = new_type # Trigger the update of display format if self.value is not None: self.value_changed(self.value) @Property(int) def bufferSize(self): """ The maximum number of entries to show. When maximum is exceeded the widget is cleared. Returns ------- int """ return int(self._buffer_size) @bufferSize.setter def bufferSize(self, value): """ The maximum number of entries to show. When maximum is exceeded the widget is cleared. Parameters ---------- value : int """ self._buffer_size = int(value) @Property(bool) def prependDateTime(self): """ Define if the date and time information will be prepended to the text. Returns ------- bool """ return self._prepend_date_time @prependDateTime.setter def prependDateTime(self, value): """ Define if the date and time information will be prepended to the text. Parameters ---------- value : bool """ self._prepend_date_time = bool(value) @Property(str) def dateTimeFmt(self): """ Define the format of the datetime information to be prepended. Returns ------- str """ return self._date_time_fmt @dateTimeFmt.setter def dateTimeFmt(self, value): """ Define the format of the datetime information to be prepended. Parameters ---------- value : str """ self._date_time_fmt = str(value)
class ProclogModel(QStandardItemModel, ProclogModelRoles): Q_ENUMS(ProclogModelRoles) appInterfaceChanged = Signal() def __init__(self, parent=None): super(ProclogModel, self).__init__(parent) self._app_interface = None self.setSortRole(self.IdRole) @Property(AppInterface, notify=appInterfaceChanged) def appInterface(self): return self._app_interface @appInterface.setter def appInterface(self, new_interface): if new_interface == self._app_interface: return old_interface = self._app_interface self._app_interface = new_interface self.appInterfaceChanged.emit() if old_interface: old_interface.proclogChanged.disconnect(self.refresh) if new_interface: new_interface.proclogChanged.connect(self.refresh) self.refresh() @Slot() def refresh(self): self.clear() if not self._app_interface: logger.warning('cannot refresh without an appInterface') return proclog = self._app_interface.proclog previous_level = 0 item_stack = deque([self]) for i, log in enumerate(proclog): level = 0 name = '' info = {} for item in log: if item == '->': level += 1 else: name = item['name'] info = item['information'] item = QStandardItem(name) item.setData(True, self.IsProcedureRole) item.setData('', self.ValueRole) item.setData(i + 1, self.IdRole) if isinstance(info, dict) and len(info): data_item = QStandardItem(self.tr('data')) data_item.setData(False, self.IsProcedureRole) data_item.setData('', self.ValueRole) for k, v in info.items(): entry_item = QStandardItem(k) if isinstance(v, dict) and 'address' in v and 'value' in v: value_item = QStandardItem('value') value_item.setData(str(v['value']), self.ValueRole) value_item.setData(False, self.IsProcedureRole) entry_item.appendRow(value_item) value_item = QStandardItem('address') value_item.setData(str(v['address']), self.ValueRole) value_item.setData(False, self.IsProcedureRole) entry_item.appendRow(value_item) value = '' else: value = str(v) entry_item.setData(value, self.ValueRole) entry_item.setData(False, self.IsProcedureRole) data_item.appendRow(entry_item) item.appendRow(data_item) elif isinstance(info, str): data_item = QStandardItem(self.tr('info')) data_item.setData(False, self.IsProcedureRole) data_item.setData(info, self.ValueRole) item.appendRow(data_item) if level > previous_level: previous_level = level elif level == previous_level: item_stack.pop() else: item_stack.pop() while previous_level > level and previous_level > 0: item_stack.pop() previous_level -= 1 item_stack[-1].appendRow(item) item_stack.append(item) def roleNames(self): return self.role_names()
class PyDMLogDisplay(QWidget, LogLevels): """ Standard display for Log Output This widget handles instantating a ``GuiHandler`` and displaying log messages to a ``QPlainTextEdit``. The level of the log can be changed from inside the widget itself, allowing users to select from any of the ``.levels`` specified by the widget. Parameters ---------- parent : QObject, optional logname : str Name of log to display in widget level : logging.Level Initial level of log display """ Q_ENUMS(LogLevels) LogLevels = LogLevels terminator = '\n' default_format = '%(asctime)s %(message)s' default_level = logging.INFO def __init__(self, parent=None, logname=None, level=logging.NOTSET): QWidget.__init__(self, parent=parent) # Create Widgets self.label = QLabel('Minimum displayed log level: ', parent=self) self.combo = QComboBox(parent=self) self.text = QPlainTextEdit(parent=self) self.text.setReadOnly(True) self.clear_btn = QPushButton("Clear", parent=self) # Create layout layout = QVBoxLayout() level_control = QHBoxLayout() level_control.addWidget(self.label) level_control.addWidget(self.combo) layout.addLayout(level_control) layout.addWidget(self.text) layout.addWidget(self.clear_btn) self.setLayout(layout) # Allow QCombobox to control log level for log_level, value in LogLevels.as_dict().items(): self.combo.addItem(log_level, value) self.combo.currentIndexChanged[str].connect(self.setLevel) # Allow QPushButton to clear log text self.clear_btn.clicked.connect(self.clear) # Create a handler with the default format self.handler = GuiHandler(level=level, parent=self) self.logFormat = self.default_format self.handler.message.connect(self.write) # Create logger. Either as a root or given logname self.log = None self.level = None self.logName = logname or '' self.logLevel = level self.destroyed.connect(functools.partial(logger_destroyed, self.log)) def sizeHint(self): return QSize(400, 300) @Property(LogLevels) def logLevel(self): return self.level @logLevel.setter def logLevel(self, level): if level != self.level: self.level = level idx = self.combo.findData(level) self.combo.setCurrentIndex(idx) @Property(str) def logName(self): """Name of associated log""" return self.log.name @logName.setter def logName(self, name): # Disconnect prior log from handler if self.log: self.log.removeHandler(self.handler) # Reattach handler to new handler self.log = logging.getLogger(name) # Ensure that the log matches level of handler # only if the handler level is less than the log. if self.log.level < self.handler.level: self.log.setLevel(self.handler.level) # Attach preconfigured handler self.log.addHandler(self.handler) @Property(str) def logFormat(self): """Format for log messages""" return self.handler.formatter._fmt @logFormat.setter def logFormat(self, fmt): self.handler.setFormatter(logging.Formatter(fmt)) @Slot(str) def write(self, message): """Write a message to the log display""" # We split the incoming message by new lines. In prior iterations of # this widget it was discovered that large blocks of text cause issues # at the Qt level. for msg in message.split(self.terminator): self.text.appendPlainText(msg) @Slot() def clear(self): """Clear the text area.""" self.text.clear() @Slot(str) def setLevel(self, level): """Set the level of the contained logger""" # Get the level from the incoming string specification try: level = getattr(logging, level.upper()) except AttributeError as exc: logger.exception("Invalid logging level specified %s", level.upper()) else: # Set the existing handler and logger to this level self.handler.setLevel(level) if self.log.level > self.handler.level or self.log.level == logging.NOTSET: self.log.setLevel(self.handler.level)
class SiriusSpectrogramView(GraphicsLayoutWidget, PyDMWidget, PyDMColorMap, ReadingOrder): """ A SpectrogramView with support for Channels and more from PyDM. If there is no :attr:`channelWidth` it is possible to define the width of the image with the :attr:`width` property. The :attr:`normalizeData` property defines if the colors of the images are relative to the :attr:`colorMapMin` and :attr:`colorMapMax` property or to the minimum and maximum values of the image. Use the :attr:`newImageSignal` to hook up to a signal that is emitted when a new image is rendered in the widget. Parameters ---------- parent : QWidget The parent widget for the Label image_channel : str, optional The channel to be used by the widget for the image data. xaxis_channel : str, optional The channel to be used by the widget to receive the image width (if ReadingOrder == Clike), and to set the xaxis values yaxis_channel : str, optional The channel to be used by the widget to receive the image width (if ReadingOrder == Fortranlike), and to set the yaxis values background : QColor, optional QColor to set the background color of the GraphicsView """ Q_ENUMS(PyDMColorMap) Q_ENUMS(ReadingOrder) color_maps = cmaps def __init__(self, parent=None, image_channel=None, xaxis_channel=None, yaxis_channel=None, roioffsetx_channel=None, roioffsety_channel=None, roiwidth_channel=None, roiheight_channel=None, title='', background='w', image_width=0, image_height=0): """Initialize widget.""" GraphicsLayoutWidget.__init__(self, parent) PyDMWidget.__init__(self) self.thread = None self._imagechannel = None self._xaxischannel = None self._yaxischannel = None self._roioffsetxchannel = None self._roioffsetychannel = None self._roiwidthchannel = None self._roiheightchannel = None self._channels = 7 * [ None, ] self.image_waveform = np.zeros(0) self._image_width = image_width if not xaxis_channel else 0 self._image_height = image_height if not yaxis_channel else 0 self._roi_offsetx = 0 self._roi_offsety = 0 self._roi_width = 0 self._roi_height = 0 self._normalize_data = False self._auto_downsample = True self._last_yaxis_data = None self._last_xaxis_data = None self._auto_colorbar_lims = True self.format_tooltip = '{0:.4g}, {1:.4g}' # ViewBox and imageItem. self._view = ViewBox() self._image_item = ImageItem() self._view.addItem(self._image_item) # ROI self.ROICurve = PlotCurveItem([0, 0, 0, 0, 0], [0, 0, 0, 0, 0]) self.ROIColor = QColor('red') pen = mkPen() pen.setColor(QColor('transparent')) pen.setWidth(1) self.ROICurve.setPen(pen) self._view.addItem(self.ROICurve) # Axis. self.xaxis = AxisItem('bottom') self.xaxis.setPen(QColor(0, 0, 0)) if not xaxis_channel: self.xaxis.setVisible(False) self.yaxis = AxisItem('left') self.yaxis.setPen(QColor(0, 0, 0)) if not yaxis_channel: self.yaxis.setVisible(False) # Colorbar legend. self.colorbar = _GradientLegend() # Title. start_row = 0 if title: self.title = LabelItem(text=title, color='#000000') self.addItem(self.title, 0, 0, 1, 3) start_row = 1 # Set layout. self.addItem(self._view, start_row, 1) self.addItem(self.yaxis, start_row, 0) self.addItem(self.colorbar, start_row, 2) self.addItem(self.xaxis, start_row + 1, 1) self.setBackground(background) self.ci.layout.setColumnSpacing(0, 0) self.ci.layout.setRowSpacing(start_row, 0) # Set color map limits. self.cm_min = 0.0 self.cm_max = 255.0 # Set default reading order of numpy array data to Clike. self._reading_order = ReadingOrder.Clike # Make a right-click menu for changing the color map. self.cm_group = QActionGroup(self) self.cmap_for_action = {} for cm in self.color_maps: action = self.cm_group.addAction(cmap_names[cm]) action.setCheckable(True) self.cmap_for_action[action] = cm # Set the default colormap. self._cm_colors = None self.colorMap = PyDMColorMap.Inferno # Setup the redraw timer. self.needs_redraw = False self.redraw_timer = QTimer(self) self.redraw_timer.timeout.connect(self.redrawImage) self._redraw_rate = 30 self.maxRedrawRate = self._redraw_rate self.newImageSignal = self._image_item.sigImageChanged # Set Channels. self.imageChannel = image_channel self.xAxisChannel = xaxis_channel self.yAxisChannel = yaxis_channel self.ROIOffsetXChannel = roioffsetx_channel self.ROIOffsetYChannel = roioffsety_channel self.ROIWidthChannel = roiwidth_channel self.ROIHeightChannel = roiheight_channel # --- Context menu --- def widget_ctx_menu(self): """ Fetch the Widget specific context menu. It will be populated with additional tools by `assemble_tools_menu`. Returns ------- QMenu or None If the return of this method is None a new QMenu will be created by `assemble_tools_menu`. """ self.menu = ViewBoxMenu(self._view) cm_menu = self.menu.addMenu("Color Map") for act in self.cmap_for_action.keys(): cm_menu.addAction(act) cm_menu.triggered.connect(self._changeColorMap) return self.menu # --- Colormap methods --- def _changeColorMap(self, action): """ Method invoked by the colormap Action Menu. Changes the current colormap used to render the image. Parameters ---------- action : QAction """ self.colorMap = self.cmap_for_action[action] @Property(float) def colorMapMin(self): """ Minimum value for the colormap. Returns ------- float """ return self.cm_min @colorMapMin.setter @Slot(float) def colorMapMin(self, new_min): """ Set the minimum value for the colormap. Parameters ---------- new_min : float """ if self.cm_min != new_min: self.cm_min = new_min if self.cm_min > self.cm_max: self.cm_max = self.cm_min @Property(float) def colorMapMax(self): """ Maximum value for the colormap. Returns ------- float """ return self.cm_max @colorMapMax.setter @Slot(float) def colorMapMax(self, new_max): """ Set the maximum value for the colormap. Parameters ---------- new_max : float """ if self.cm_max != new_max: self.cm_max = new_max if self.cm_max < self.cm_min: self.cm_min = self.cm_max def setColorMapLimits(self, mn, mx): """ Set the limit values for the colormap. Parameters ---------- mn : int The lower limit mx : int The upper limit """ if mn >= mx: return self.cm_max = mx self.cm_min = mn @Property(PyDMColorMap) def colorMap(self): """ Return the color map used by the SpectrogramView. Returns ------- PyDMColorMap """ return self._colormap @colorMap.setter def colorMap(self, new_cmap): """ Set the color map used by the SpectrogramView. Parameters ------- new_cmap : PyDMColorMap """ self._colormap = new_cmap self._cm_colors = self.color_maps[new_cmap] self.setColorMap() for action in self.cm_group.actions(): if self.cmap_for_action[action] == self._colormap: action.setChecked(True) else: action.setChecked(False) def setColorMap(self, cmap=None): """ Update the image colormap. Parameters ---------- cmap : ColorMap """ if not cmap: if not self._cm_colors.any(): return # Take default values pos = np.linspace(0.0, 1.0, num=len(self._cm_colors)) cmap = ColorMap(pos, self._cm_colors) self._view.setBackgroundColor(cmap.map(0)) lut = cmap.getLookupTable(0.0, 1.0, alpha=False) self.colorbar.setIntColorScale(colors=lut) self._image_item.setLookupTable(lut) # --- Connection Slots --- @Slot(bool) def image_connection_state_changed(self, conn): """ Callback invoked when the Image Channel connection state is changed. Parameters ---------- conn : bool The new connection state. """ if conn: self.redraw_timer.start() else: self.redraw_timer.stop() @Slot(bool) def yaxis_connection_state_changed(self, connected): """ Callback invoked when the TimeAxis Channel connection state is changed. Parameters ---------- conn : bool The new connection state. """ self._timeaxis_connected = connected @Slot(bool) def roioffsetx_connection_state_changed(self, conn): """ Run when the ROIOffsetX Channel connection state changes. Parameters ---------- conn : bool The new connection state. """ if not conn: self._roi_offsetx = 0 @Slot(bool) def roioffsety_connection_state_changed(self, conn): """ Run when the ROIOffsetY Channel connection state changes. Parameters ---------- conn : bool The new connection state. """ if not conn: self._roi_offsety = 0 @Slot(bool) def roiwidth_connection_state_changed(self, conn): """ Run when the ROIWidth Channel connection state changes. Parameters ---------- conn : bool The new connection state. """ if not conn: self._roi_width = 0 @Slot(bool) def roiheight_connection_state_changed(self, conn): """ Run when the ROIHeight Channel connection state changes. Parameters ---------- conn : bool The new connection state. """ if not conn: self._roi_height = 0 # --- Value Slots --- @Slot(np.ndarray) def image_value_changed(self, new_image): """ Callback invoked when the Image Channel value is changed. We try to do as little as possible in this method, because it gets called every time the image channel updates, which might be extremely often. Basically just store the data, and set a flag requesting that the image be redrawn. Parameters ---------- new_image : np.ndarray The new image data. This can be a flat 1D array, or a 2D array. """ if new_image is None or new_image.size == 0: return logging.debug("SpectrogramView Received New Image: Needs Redraw->True") self.image_waveform = new_image self.needs_redraw = True if not self._image_height and self._image_width: self._image_height = new_image.size / self._image_width elif not self._image_width and self._image_height: self._image_width = new_image.size / self._image_height @Slot(np.ndarray) @Slot(float) def xaxis_value_changed(self, new_array): """ Callback invoked when the Image Width Channel value is changed. Parameters ---------- new_array : np.ndarray The new x axis array """ if new_array is None: return if isinstance(new_array, float): new_array = np.array([ new_array, ]) self._last_xaxis_data = new_array if self._reading_order == self.Clike: self._image_width = new_array.size else: self._image_height = new_array.size self.needs_redraw = True @Slot(np.ndarray) @Slot(float) def yaxis_value_changed(self, new_array): """ Callback invoked when the TimeAxis Channel value is changed. Parameters ---------- new_array : np.array The new y axis array """ if new_array is None: return if isinstance(new_array, float): new_array = np.array([ new_array, ]) self._last_yaxis_data = new_array if self._reading_order == self.Fortranlike: self._image_width = new_array.size else: self._image_height = new_array.size self.needs_redraw = True @Slot(int) def roioffsetx_value_changed(self, new_offset): """ Run when the ROIOffsetX Channel value changes. Parameters ---------- new_offsetx : int The new image ROI horizontal offset """ if new_offset is None: return self._roi_offsetx = new_offset self.redrawROI() @Slot(int) def roioffsety_value_changed(self, new_offset): """ Run when the ROIOffsetY Channel value changes. Parameters ---------- new_offsety : int The new image ROI vertical offset """ if new_offset is None: return self._roi_offsety = new_offset self.redrawROI() @Slot(int) def roiwidth_value_changed(self, new_width): """ Run when the ROIWidth Channel value changes. Parameters ---------- new_width : int The new image ROI width """ if new_width is None: return self._roi_width = int(new_width) self.redrawROI() @Slot(int) def roiheight_value_changed(self, new_height): """ Run when the ROIHeight Channel value changes. Parameters ---------- new_height : int The new image ROI height """ if new_height is None: return self._roi_height = int(new_height) self.redrawROI() # --- Image update methods --- def process_image(self, image): """ Boilerplate method. To be used by applications in order to add calculations and also modify the image before it is displayed at the widget. .. warning:: This code runs in a separated QThread so it **MUST** not try to write to QWidgets. Parameters ---------- image : np.ndarray The Image Data as a 2D numpy array Returns ------- np.ndarray The Image Data as a 2D numpy array after processing. """ return image def redrawImage(self): """ Set the image data into the ImageItem, if needed. If necessary, reshape the image to 2D first. """ if self.thread is not None and not self.thread.isFinished(): logger.warning( "Image processing has taken longer than the refresh rate.") return self.thread = SpectrogramUpdateThread(self) self.thread.updateSignal.connect(self._updateDisplay) logging.debug("SpectrogramView RedrawImage Thread Launched") self.thread.start() @Slot(list) def _updateDisplay(self, data): logging.debug("SpectrogramView Update Display with new image") # Update axis if self._last_xaxis_data is not None: szx = self._last_xaxis_data.size xMin = self._last_xaxis_data.min() xMax = self._last_xaxis_data.max() else: szx = self.imageWidth if self.readingOrder == self.Clike \ else self.imageHeight xMin = 0 xMax = szx if self._last_yaxis_data is not None: szy = self._last_yaxis_data.size yMin = self._last_yaxis_data.min() yMax = self._last_yaxis_data.max() else: szy = self.imageHeight if self.readingOrder == self.Clike \ else self.imageWidth yMin = 0 yMax = szy self.xaxis.setRange(xMin, xMax) self.yaxis.setRange(yMin, yMax) self._view.setLimits(xMin=0, xMax=szx, yMin=0, yMax=szy, minXRange=szx, maxXRange=szx, minYRange=szy, maxYRange=szy) # Update image if self.autoSetColorbarLims: self.colorbar.setLimits(data) mini, maxi = data[0], data[1] img = data[2] self._image_item.setLevels([mini, maxi]) self._image_item.setImage(img, autoLevels=False, autoDownsample=self.autoDownsample) # ROI update methods def redrawROI(self): startx = self._roi_offsetx endx = self._roi_offsetx + self._roi_width starty = self._roi_offsety endy = self._roi_offsety + self._roi_height self.ROICurve.setData([startx, startx, endx, endx, startx], [starty, endy, endy, starty, starty]) def showROI(self, show): """Set ROI visibility.""" pen = mkPen() if show: pen.setColor(self.ROIColor) else: pen.setColor(QColor('transparent')) self.ROICurve.setPen(pen) # --- Properties --- @Property(bool) def autoDownsample(self): """ Return if we should or not apply the autoDownsample option. Return ------ bool """ return self._auto_downsample @autoDownsample.setter def autoDownsample(self, new_value): """ Whether we should or not apply the autoDownsample option. Parameters ---------- new_value: bool """ if new_value != self._auto_downsample: self._auto_downsample = new_value @Property(bool) def autoSetColorbarLims(self): """ Return if we should or not auto set colorbar limits. Return ------ bool """ return self._auto_colorbar_lims @autoSetColorbarLims.setter def autoSetColorbarLims(self, new_value): """ Whether we should or not auto set colorbar limits. Parameters ---------- new_value: bool """ if new_value != self._auto_colorbar_lims: self._auto_colorbar_lims = new_value @Property(int) def imageWidth(self): """ Return the width of the image. Return ------ int """ return self._image_width @imageWidth.setter def imageWidth(self, new_width): """ Set the width of the image. Can be overridden by :attr:`xAxisChannel` and :attr:`yAxisChannel`. Parameters ---------- new_width: int """ boo = self._image_width != int(new_width) boo &= not self._xaxischannel boo &= not self._yaxischannel if boo: self._image_width = int(new_width) @Property(int) def imageHeight(self): """ Return the height of the image. Return ------ int """ return self._image_height @Property(int) def ROIOffsetX(self): """ Return the ROI offset in X axis in pixels. Return ------ int """ return self._roi_offsetx @ROIOffsetX.setter def ROIOffsetX(self, new_offset): """ Set the ROI offset in X axis in pixels. Can be overridden by :attr:`ROIOffsetXChannel`. Parameters ---------- new_offset: int """ if new_offset is None: return boo = self._roi_offsetx != int(new_offset) boo &= not self._roioffsetxchannel if boo: self._roi_offsetx = int(new_offset) self.redrawROI() @Property(int) def ROIOffsetY(self): """ Return the ROI offset in Y axis in pixels. Return ------ int """ return self._roi_offsety @ROIOffsetY.setter def ROIOffsetY(self, new_offset): """ Set the ROI offset in Y axis in pixels. Can be overridden by :attr:`ROIOffsetYChannel`. Parameters ---------- new_offset: int """ if new_offset is None: return boo = self._roi_offsety != int(new_offset) boo &= not self._roioffsetychannel if boo: self._roi_offsety = int(new_offset) self.redrawROI() @Property(int) def ROIWidth(self): """ Return the ROI width in pixels. Return ------ int """ return self._roi_width @ROIWidth.setter def ROIWidth(self, new_width): """ Set the ROI width in pixels. Can be overridden by :attr:`ROIWidthChannel`. Parameters ---------- new_width: int """ if new_width is None: return boo = self._roi_width != int(new_width) boo &= not self._roiwidthchannel if boo: self._roi_width = int(new_width) self.redrawROI() @Property(int) def ROIHeight(self): """ Return the ROI height in pixels. Return ------ int """ return self._roi_height @ROIHeight.setter def ROIHeight(self, new_height): """ Set the ROI height in pixels. Can be overridden by :attr:`ROIHeightChannel`. Parameters ---------- new_height: int """ if new_height is None: return boo = self._roi_height != int(new_height) boo &= not self._roiheightchannel if boo: self._roi_height = int(new_height) self.redrawROI() @Property(bool) def normalizeData(self): """ Return True if the colors are relative to data maximum and minimum. Returns ------- bool """ return self._normalize_data @normalizeData.setter @Slot(bool) def normalizeData(self, new_norm): """ Define if the colors are relative to minimum and maximum of the data. Parameters ---------- new_norm: bool """ if self._normalize_data != new_norm: self._normalize_data = new_norm @Property(ReadingOrder) def readingOrder(self): """ Return the reading order of the :attr:`imageChannel` array. Returns ------- ReadingOrder """ return self._reading_order @readingOrder.setter def readingOrder(self, order): """ Set reading order of the :attr:`imageChannel` array. Parameters ---------- order: ReadingOrder """ if self._reading_order != order: self._reading_order = order if order == self.Clike: if self._last_xaxis_data is not None: self._image_width = self._last_xaxis_data.size if self._last_yaxis_data is not None: self._image_height = self._last_yaxis_data.size elif order == self.Fortranlike: if self._last_yaxis_data is not None: self._image_width = self._last_yaxis_data.size if self._last_xaxis_data is not None: self._image_height = self._last_xaxis_data.size @Property(int) def maxRedrawRate(self): """ The maximum rate (in Hz) at which the plot will be redrawn. The plot will not be redrawn if there is not new data to draw. Returns ------- int """ return self._redraw_rate @maxRedrawRate.setter def maxRedrawRate(self, redraw_rate): """ The maximum rate (in Hz) at which the plot will be redrawn. The plot will not be redrawn if there is not new data to draw. Parameters ------- redraw_rate : int """ self._redraw_rate = redraw_rate self.redraw_timer.setInterval(int((1.0 / self._redraw_rate) * 1000)) # --- Events rederivations --- def keyPressEvent(self, ev): """Handle keypress events.""" return def mouseMoveEvent(self, ev): if not self._image_item.width() or not self._image_item.height(): super().mouseMoveEvent(ev) return pos = ev.pos() posaux = self._image_item.mapFromDevice(ev.pos()) if posaux.x() < 0 or posaux.x() >= self._image_item.width() or \ posaux.y() < 0 or posaux.y() >= self._image_item.height(): super().mouseMoveEvent(ev) return pos_scene = self._view.mapSceneToView(pos) x = round(pos_scene.x()) y = round(pos_scene.y()) if self.xAxisChannel and self._last_xaxis_data is not None: maxx = len(self._last_xaxis_data) - 1 x = x if x < maxx else maxx valx = self._last_xaxis_data[x] else: valx = x if self.yAxisChannel and self._last_yaxis_data is not None: maxy = len(self._last_yaxis_data) - 1 y = y if y < maxy else maxy valy = self._last_yaxis_data[y] else: valy = y txt = self.format_tooltip.format(valx, valy) QToolTip.showText(self.mapToGlobal(pos), txt, self, self.geometry(), 5000) super().mouseMoveEvent(ev) # --- Channels --- @Property(str) def imageChannel(self): """ The channel address in use for the image data . Returns ------- str Channel address """ if self._imagechannel: return str(self._imagechannel.address) else: return '' @imageChannel.setter def imageChannel(self, value): """ The channel address in use for the image data . Parameters ---------- value : str Channel address """ if self._imagechannel != value: # Disconnect old channel if self._imagechannel: self._imagechannel.disconnect() # Create and connect new channel self._imagechannel = PyDMChannel( address=value, connection_slot=self.image_connection_state_changed, value_slot=self.image_value_changed, severity_slot=self.alarmSeverityChanged) self._channels[0] = self._imagechannel self._imagechannel.connect() @Property(str) def xAxisChannel(self): """ The channel address in use for the x-axis of image. Returns ------- str Channel address """ if self._xaxischannel: return str(self._xaxischannel.address) else: return '' @xAxisChannel.setter def xAxisChannel(self, value): """ The channel address in use for the x-axis of image. Parameters ---------- value : str Channel address """ if self._xaxischannel != value: # Disconnect old channel if self._xaxischannel: self._xaxischannel.disconnect() # Create and connect new channel self._xaxischannel = PyDMChannel( address=value, connection_slot=self.connectionStateChanged, value_slot=self.xaxis_value_changed, severity_slot=self.alarmSeverityChanged) self._channels[1] = self._xaxischannel self._xaxischannel.connect() @Property(str) def yAxisChannel(self): """ The channel address in use for the time axis. Returns ------- str Channel address """ if self._yaxischannel: return str(self._yaxischannel.address) else: return '' @yAxisChannel.setter def yAxisChannel(self, value): """ The channel address in use for the time axis. Parameters ---------- value : str Channel address """ if self._yaxischannel != value: # Disconnect old channel if self._yaxischannel: self._yaxischannel.disconnect() # Create and connect new channel self._yaxischannel = PyDMChannel( address=value, connection_slot=self.yaxis_connection_state_changed, value_slot=self.yaxis_value_changed, severity_slot=self.alarmSeverityChanged) self._channels[2] = self._yaxischannel self._yaxischannel.connect() @Property(str) def ROIOffsetXChannel(self): """ Return the channel address in use for the image ROI horizontal offset. Returns ------- str Channel address """ if self._roioffsetxchannel: return str(self._roioffsetxchannel.address) else: return '' @ROIOffsetXChannel.setter def ROIOffsetXChannel(self, value): """ Return the channel address in use for the image ROI horizontal offset. Parameters ---------- value : str Channel address """ if self._roioffsetxchannel != value: # Disconnect old channel if self._roioffsetxchannel: self._roioffsetxchannel.disconnect() # Create and connect new channel self._roioffsetxchannel = PyDMChannel( address=value, connection_slot=self.roioffsetx_connection_state_changed, value_slot=self.roioffsetx_value_changed, severity_slot=self.alarmSeverityChanged) self._channels[3] = self._roioffsetxchannel self._roioffsetxchannel.connect() @Property(str) def ROIOffsetYChannel(self): """ Return the channel address in use for the image ROI vertical offset. Returns ------- str Channel address """ if self._roioffsetychannel: return str(self._roioffsetychannel.address) else: return '' @ROIOffsetYChannel.setter def ROIOffsetYChannel(self, value): """ Return the channel address in use for the image ROI vertical offset. Parameters ---------- value : str Channel address """ if self._roioffsetychannel != value: # Disconnect old channel if self._roioffsetychannel: self._roioffsetychannel.disconnect() # Create and connect new channel self._roioffsetychannel = PyDMChannel( address=value, connection_slot=self.roioffsety_connection_state_changed, value_slot=self.roioffsety_value_changed, severity_slot=self.alarmSeverityChanged) self._channels[4] = self._roioffsetychannel self._roioffsetychannel.connect() @Property(str) def ROIWidthChannel(self): """ Return the channel address in use for the image ROI width. Returns ------- str Channel address """ if self._roiwidthchannel: return str(self._roiwidthchannel.address) else: return '' @ROIWidthChannel.setter def ROIWidthChannel(self, value): """ Return the channel address in use for the image ROI width. Parameters ---------- value : str Channel address """ if self._roiwidthchannel != value: # Disconnect old channel if self._roiwidthchannel: self._roiwidthchannel.disconnect() # Create and connect new channel self._roiwidthchannel = PyDMChannel( address=value, connection_slot=self.roiwidth_connection_state_changed, value_slot=self.roiwidth_value_changed, severity_slot=self.alarmSeverityChanged) self._channels[5] = self._roiwidthchannel self._roiwidthchannel.connect() @Property(str) def ROIHeightChannel(self): """ Return the channel address in use for the image ROI height. Returns ------- str Channel address """ if self._roiheightchannel: return str(self._roiheightchannel.address) else: return '' @ROIHeightChannel.setter def ROIHeightChannel(self, value): """ Return the channel address in use for the image ROI height. Parameters ---------- value : str Channel address """ if self._roiheightchannel != value: # Disconnect old channel if self._roiheightchannel: self._roiheightchannel.disconnect() # Create and connect new channel self._roiheightchannel = PyDMChannel( address=value, connection_slot=self.roiheight_connection_state_changed, value_slot=self.roiheight_value_changed, severity_slot=self.alarmSeverityChanged) self._channels[6] = self._roiheightchannel self._roiheightchannel.connect() def channels(self): """ Return the channels being used for this Widget. Returns ------- channels : list List of PyDMChannel objects """ return self._channels def channels_for_tools(self): """Return channels for tools.""" return [self._imagechannel]
class TyphonDeviceDisplay(TyphonBase, TyphonDesignerMixin, DisplayTypes): """ Main Panel display for a signal Ophyd Device This widget lays out all of the architecture for a single Ophyd display. The structure matches an ophyd Device, but for this specific instantation, one is not required to be given. There are four main panels available; :attr:`.read_panel`, :attr:`.config_panel`, :attr:`.method_panel`. These each provide a quick way to organize signals and methods by their importance to an operator. Because each panel can be hidden interactively, the screen works as both an expert and novice entry point for users. By default, widgets are hidden until contents are added. For instance, if you do not add any methods to the main panel it will not be visible. This contains the widgets for all of the root devices signals, any methods you would like to display, and an optional image. As with ``typhon`` convention, the base initialization sets up the widgets and the ``.from_device`` class method will automatically populate them. Parameters ---------- name: str, optional Name to displayed at the top of the panel image: str, optional Path to image file to displayed at the header parent: QWidget, optional """ # Template types and defaults Q_ENUMS(DisplayTypes) TemplateEnum = DisplayTypes.to_enum() # For convenience def __init__(self, parent=None, **kwargs): # Intialize background variable self._forced_template = '' self._last_macros = dict() self._main_widget = None self._display_type = DisplayTypes.detailed_screen self.templates = dict((_typ.name, os.path.join(ui_dir, _typ.name + '.ui')) for _typ in self.TemplateEnum) # Set this to None first so we don't render super().__init__(parent=parent) # Initialize blank UI self.setLayout(QHBoxLayout()) self.layout().setContentsMargins(0, 0, 0, 0) # Load template self.load_template() @property def current_template(self): """Current template being rendered""" if self._forced_template: return self._forced_template # Search in the last macros, maybe our device told us what to do template_key = self.TemplateEnum(self._display_type).name return self.templates[template_key] @Property(DisplayTypes) def display_type(self): return self._display_type @display_type.setter def display_type(self, value): # Store our new value if self._display_type != value: self._display_type = value self.load_template(macros=self._last_macros) @Property(str, designable=False) def device_class(self): """Full class with module name of loaded device""" if getattr(self, 'devices', []): device_class = self.devices[0].__class__ return '.'.join((device_class.__module__, device_class.__name__)) return '' @Property(str, designable=False) def device_name(self): "Name of loaded device" if getattr(self, 'devices', []): return self.devices[0].name return '' def load_template(self, macros=None): """ Load a new template Parameters ---------- template: str Absolute path to template location macros: dict, optional Macro substitutions to be made in the file """ # 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 if self.layout() is None: logger.debug("Widget not initialized, do not load template") return # Clear anything that exists in the current layout if self._main_widget: logger.debug("Clearing existing layout ...") clear_layout(self.layout()) # Assemble our macros macros = macros or dict() for display_type in self.templates: value = macros.get(display_type) if value: logger.debug("Found new template %r for %r", value, display_type) self.templates[display_type] = value # Store macros self._last_macros = macros try: self._main_widget = Display(ui_filename=self.current_template, macros=macros) # Add device to all children widgets if self.devices: for widget in self._main_widget.findChildren(TyphonBase): widget.add_device(self.devices[0]) except (FileNotFoundError, IsADirectoryError): logger.exception("Unable to load file %r", self.current_template) self._main_widget = QWidget() finally: self.layout().addWidget(self._main_widget) reload_widget_stylesheet(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_template(macros=self._last_macros) def add_device(self, device, macros=None): """ Add a Device and signals to the TyphonDeviceDisplay Parameters ---------- device: ophyd.Device macros: dict, optional Set of macros to reload the template with. There are two fallback options attempted if no information is passed in. First, if the device has an ``md`` attribute after being loaded from a ``happi`` database, that information will be passed in as macros. Finally, if no ``name`` field is passed in, we ensure the ``device.name`` is entered as well. """ # 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) # Try and collect macros from device if not macros: if hasattr(device, 'md'): macros = device.md.post() else: macros = dict() # Ensure we at least pass in the device name if 'name' not in macros: macros['name'] = device.name # Reload template self.load_template(macros=macros) @classmethod def from_device(cls, device, template=None, macros=None): """ Create a new TyphonDeviceDisplay 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 """ display = cls() # Reset the template if provided if template: display.force_template = template # Add the device display.add_device(device, macros=macros) return display @Slot(object) def _tx(self, value): """Receive information from happi channel""" self.add_device(value['obj'], macros=value['md'])
class PyDMImageView(ImageView, PyDMWidget, PyDMColorMap, ReadingOrder): """ A PyQtGraph ImageView with support for Channels and more from PyDM. If there is no :attr:`channelWidth` it is possible to define the width of the image with the :attr:`width` property. The :attr:`normalizeData` property defines if the colors of the images are relative to the :attr:`colorMapMin` and :attr:`colorMapMax` property or to the minimum and maximum values of the image. Use the :attr:`newImageSignal` to hook up to a signal that is emitted when a new image is rendered in the widget. Parameters ---------- parent : QWidget The parent widget for the Label image_channel : str, optional The channel to be used by the widget for the image data. width_channel : str, optional The channel to be used by the widget to receive the image width information """ ReadingOrder = ReadingOrder Q_ENUMS(ReadingOrder) Q_ENUMS(PyDMColorMap) color_maps = cmaps def __init__(self, parent=None, image_channel=None, width_channel=None): """Initialize widget.""" # Set the default colormap. self._colormap = PyDMColorMap.Inferno self._cm_colors = None self._imagechannel = None self._widthchannel = None self.image_waveform = np.zeros(0) self._image_width = 0 self._normalize_data = False self._auto_downsample = True self._show_axes = False # Set default reading order of numpy array data to Fortranlike. self._reading_order = ReadingOrder.Fortranlike self._redraw_rate = 30 # Set color map limits. self.cm_min = 0.0 self.cm_max = 255.0 plot_item = PlotItem() ImageView.__init__(self, parent, view=plot_item) PyDMWidget.__init__(self) self._channels = [None, None] self.thread = None self.axes = dict({'t': None, "x": 0, "y": 1, "c": None}) self.showAxes = self._show_axes # Hide some itens of the widget. self.ui.histogram.hide() self.getImageItem().sigImageChanged.disconnect( self.ui.histogram.imageChanged) self.ui.roiBtn.hide() self.ui.menuBtn.hide() # Make a right-click menu for changing the color map. self.cm_group = QActionGroup(self) self.cmap_for_action = {} for cm in self.color_maps: action = self.cm_group.addAction(cmap_names[cm]) action.setCheckable(True) self.cmap_for_action[action] = cm self.colorMap = self._colormap # Setup the redraw timer. self.needs_redraw = False self.redraw_timer = QTimer(self) self.redraw_timer.timeout.connect(self.redrawImage) self.maxRedrawRate = self._redraw_rate self.newImageSignal = self.getImageItem().sigImageChanged # Set live channels if requested on initialization if image_channel: self.imageChannel = image_channel or '' if width_channel: self.widthChannel = width_channel or '' @Property(str, designable=False) def channel(self): return @channel.setter def channel(self, ch): if not ch: return logger.info("Use the imageChannel property with the ImageView widget.") return def widget_ctx_menu(self): """ Fetch the Widget specific context menu. It will be populated with additional tools by `assemble_tools_menu`. Returns ------- QMenu or None If the return of this method is None a new QMenu will be created by `assemble_tools_menu`. """ self.menu = ViewBoxMenu(self.getView().getViewBox()) cm_menu = self.menu.addMenu("Color Map") for act in self.cmap_for_action.keys(): cm_menu.addAction(act) cm_menu.triggered.connect(self._changeColorMap) return self.menu def _changeColorMap(self, action): """ Method invoked by the colormap Action Menu. Changes the current colormap used to render the image. Parameters ---------- action : QAction """ self.colorMap = self.cmap_for_action[action] @Property(float) def colorMapMin(self): """ Minimum value for the colormap. Returns ------- float """ return self.cm_min @colorMapMin.setter @Slot(float) def colorMapMin(self, new_min): """ Set the minimum value for the colormap. Parameters ---------- new_min : float """ if self.cm_min != new_min: self.cm_min = new_min if self.cm_min > self.cm_max: self.cm_max = self.cm_min @Property(float) def colorMapMax(self): """ Maximum value for the colormap. Returns ------- float """ return self.cm_max @colorMapMax.setter @Slot(float) def colorMapMax(self, new_max): """ Set the maximum value for the colormap. Parameters ---------- new_max : float """ if self.cm_max != new_max: self.cm_max = new_max if self.cm_max < self.cm_min: self.cm_min = self.cm_max def setColorMapLimits(self, mn, mx): """ Set the limit values for the colormap. Parameters ---------- mn : int The lower limit mx : int The upper limit """ if mn >= mx: return self.cm_max = mx self.cm_min = mn @Property(PyDMColorMap) def colorMap(self): """ Return the color map used by the ImageView. Returns ------- PyDMColorMap """ return self._colormap @colorMap.setter def colorMap(self, new_cmap): """ Set the color map used by the ImageView. Parameters ------- new_cmap : PyDMColorMap """ self._colormap = new_cmap self._cm_colors = self.color_maps[new_cmap] self.setColorMap() for action in self.cm_group.actions(): if self.cmap_for_action[action] == self._colormap: action.setChecked(True) else: action.setChecked(False) def setColorMap(self, cmap=None): """ Update the image colormap. Parameters ---------- cmap : ColorMap """ if not cmap: if not self._cm_colors.any(): return # Take default values pos = np.linspace(0.0, 1.0, num=len(self._cm_colors)) cmap = ColorMap(pos, self._cm_colors) self.getView().getViewBox().setBackgroundColor(cmap.map(0)) lut = cmap.getLookupTable(0.0, 1.0, alpha=False) self.getImageItem().setLookupTable(lut) @Slot(bool) def image_connection_state_changed(self, conn): """ Callback invoked when the Image Channel connection state is changed. Parameters ---------- conn : bool The new connection state. """ if conn: self.redraw_timer.start() else: self.redraw_timer.stop() @Slot(np.ndarray) def image_value_changed(self, new_image): """ Callback invoked when the Image Channel value is changed. We try to do as little as possible in this method, because it gets called every time the image channel updates, which might be extremely often. Basically just store the data, and set a flag requesting that the image be redrawn. Parameters ---------- new_image : np.ndarray The new image data. This can be a flat 1D array, or a 2D array. """ if new_image is None or new_image.size == 0: return logging.debug("ImageView Received New Image - Needs Redraw -> True") self.image_waveform = new_image self.needs_redraw = True @Slot(int) def image_width_changed(self, new_width): """ Callback invoked when the Image Width Channel value is changed. Parameters ---------- new_width : int The new image width """ if new_width is None: return self._image_width = int(new_width) def process_image(self, image): """ Boilerplate method to be used by applications in order to add calculations and also modify the image before it is displayed at the widget. .. warning:: This code runs in a separated QThread so it **MUST** not try to write to QWidgets. Parameters ---------- image : np.ndarray The Image Data as a 2D numpy array Returns ------- np.ndarray The Image Data as a 2D numpy array after processing. """ return image def redrawImage(self): """ Set the image data into the ImageItem, if needed. If necessary, reshape the image to 2D first. """ if self.thread is not None and not self.thread.isFinished(): logger.warning( "Image processing has taken longer than the refresh rate.") return self.thread = ImageUpdateThread(self) self.thread.updateSignal.connect(self.__updateDisplay) logging.debug("ImageView RedrawImage Thread Launched") self.thread.start() @Slot(list) def __updateDisplay(self, data): logging.debug("ImageView Update Display with new image") mini, maxi = data[0], data[1] img = data[2] self.getImageItem().setLevels([mini, maxi]) self.getImageItem().setImage( img, autoLevels=False, autoDownsample=self.autoDownsample) @Property(bool) def autoDownsample(self): """ Return if we should or not apply the autoDownsample option to PyQtGraph. Return ------ bool """ return self._auto_downsample @autoDownsample.setter def autoDownsample(self, new_value): """ Whether we should or not apply the autoDownsample option to PyQtGraph. Parameters ---------- new_value: bool """ if new_value != self._auto_downsample: self._auto_downsample = new_value @Property(int) def imageWidth(self): """ Return the width of the image. Return ------ int """ return self._image_width @imageWidth.setter def imageWidth(self, new_width): """ Set the width of the image. Can be overridden by :attr:`widthChannel`. Parameters ---------- new_width: int """ if (self._image_width != int(new_width) and (self._widthchannel is None or self._widthchannel == '')): self._image_width = int(new_width) @Property(bool) def normalizeData(self): """ Return True if the colors are relative to data maximum and minimum. Returns ------- bool """ return self._normalize_data @normalizeData.setter @Slot(bool) def normalizeData(self, new_norm): """ Define if the colors are relative to minimum and maximum of the data. Parameters ---------- new_norm: bool """ if self._normalize_data != new_norm: self._normalize_data = new_norm @Property(ReadingOrder) def readingOrder(self): """ Return the reading order of the :attr:`imageChannel` array. Returns ------- ReadingOrder """ return self._reading_order @readingOrder.setter def readingOrder(self, new_order): """ Set reading order of the :attr:`imageChannel` array. Parameters ---------- new_order: ReadingOrder """ if self._reading_order != new_order: self._reading_order = new_order def keyPressEvent(self, ev): """Handle keypress events.""" return @Property(str) def imageChannel(self): """ The channel address in use for the image data . Returns ------- str Channel address """ if self._imagechannel: return str(self._imagechannel.address) else: return '' @imageChannel.setter def imageChannel(self, value): """ The channel address in use for the image data . Parameters ---------- value : str Channel address """ if self._imagechannel != value: # Disconnect old channel if self._imagechannel: self._imagechannel.disconnect() # Create and connect new channel self._imagechannel = PyDMChannel( address=value, connection_slot=self.image_connection_state_changed, value_slot=self.image_value_changed, severity_slot=self.alarmSeverityChanged) self._channels[0] = self._imagechannel self._imagechannel.connect() @Property(str) def widthChannel(self): """ The channel address in use for the image width . Returns ------- str Channel address """ if self._widthchannel: return str(self._widthchannel.address) else: return '' @widthChannel.setter def widthChannel(self, value): """ The channel address in use for the image width . Parameters ---------- value : str Channel address """ if self._widthchannel != value: # Disconnect old channel if self._widthchannel: self._widthchannel.disconnect() # Create and connect new channel self._widthchannel = PyDMChannel( address=value, connection_slot=self.connectionStateChanged, value_slot=self.image_width_changed, severity_slot=self.alarmSeverityChanged) self._channels[1] = self._widthchannel self._widthchannel.connect() def channels(self): """ Return the channels being used for this Widget. Returns ------- channels : list List of PyDMChannel objects """ return self._channels def channels_for_tools(self): """Return channels for tools.""" return [self._imagechannel] @Property(int) def maxRedrawRate(self): """ The maximum rate (in Hz) at which the plot will be redrawn. The plot will not be redrawn if there is not new data to draw. Returns ------- int """ return self._redraw_rate @maxRedrawRate.setter def maxRedrawRate(self, redraw_rate): """ The maximum rate (in Hz) at which the plot will be redrawn. The plot will not be redrawn if there is not new data to draw. Parameters ------- redraw_rate : int """ self._redraw_rate = redraw_rate self.redraw_timer.setInterval(int((1.0 / self._redraw_rate) * 1000)) @Property(bool) def showAxes(self): """ Whether or not axes should be shown on the widget. """ return self._show_axes @showAxes.setter def showAxes(self, show): self._show_axes = show self.getView().showAxis('left', show=show) self.getView().showAxis('bottom', show=show) @Property(float) def scaleXAxis(self): """ Sets the scale for the X Axis. For example, if your image has 100 pixels per millimeter, you can set xAxisScale to 1/100 = 0.01 to make the X Axis report in millimeter units. """ # protect against access to not yet initialized view if hasattr(self, 'view'): return self.getView().getAxis('bottom').scale return None @scaleXAxis.setter def scaleXAxis(self, new_scale): self.getView().getAxis('bottom').setScale(new_scale) @Property(float) def scaleYAxis(self): """ Sets the scale for the Y Axis. For example, if your image has 100 pixels per millimeter, you can set yAxisScale to 1/100 = 0.01 to make the Y Axis report in millimeter units. """ # protect against access to not yet initialized view if hasattr(self, 'view'): return self.getView().getAxis('left').scale return None @scaleYAxis.setter def scaleYAxis(self, new_scale): self.getView().getAxis('left').setScale(new_scale)
class PyDMLabel(QLabel, TextFormatter, PyDMWidget, DisplayFormat): Q_ENUMS(DisplayFormat) DisplayFormat = DisplayFormat """ A QLabel with support for setting the text via a PyDM Channel, or through the PyDM Rules system. Note: If a PyDMLabel is configured to use a Channel, and also with a rule which changes the 'Text' property, the behavior is undefined. Use either the Channel *or* a text rule, but not both. Parameters ---------- parent : QWidget The parent widget for the Label init_channel : str, optional The channel to be used by the widget. """ def __init__(self, parent=None, init_channel=None): QLabel.__init__(self, parent) PyDMWidget.__init__(self, init_channel=init_channel) if 'Text' not in PyDMLabel.RULE_PROPERTIES: PyDMLabel.RULE_PROPERTIES = PyDMWidget.RULE_PROPERTIES.copy() PyDMLabel.RULE_PROPERTIES.update( {'Text': ['value_changed', str]}) self.app = QApplication.instance() self.setTextFormat(Qt.PlainText) self.setTextInteractionFlags(Qt.NoTextInteraction) self.setText("PyDMLabel") self._display_format_type = self.DisplayFormat.Default self._string_encoding = "utf_8" if is_pydm_app(): self._string_encoding = self.app.get_string_encoding() @Property(DisplayFormat) def displayFormat(self): return self._display_format_type @displayFormat.setter def displayFormat(self, new_type): if self._display_format_type == new_type: return self._display_format_type = new_type if not is_qt_designer() or config.DESIGNER_ONLINE: # Trigger the update of display format self.value_changed(self.value) def value_changed(self, new_value): """ Callback invoked when the Channel value is changed. Sets the value of new_value accordingly at the Label. Parameters ---------- new_value : str, int, float, bool or np.ndarray The new value from the channel. The type depends on the channel. """ super(PyDMLabel, self).value_changed(new_value) new_value = parse_value_for_display(value=new_value, precision=self.precision, display_format_type=self._display_format_type, string_encoding=self._string_encoding, widget=self) # If the value is a string, just display it as-is, no formatting # needed. if isinstance(new_value, str): if self._show_units and self._unit != "": new_value = "{} {}".format(new_value, self._unit) self.setText(new_value) return # If the value is an enum, display the appropriate enum string for # the value. if self.enum_strings is not None and isinstance(new_value, int): try: self.setText(self.enum_strings[new_value]) except IndexError: self.setText("**INVALID**") return # If the value is a number (float or int), display it using a # format string if necessary. if isinstance(new_value, (int, float)): self.setText(self.format_string.format(new_value)) return # If you made it this far, just turn whatever the heck the value # is into a string and display it. self.setText(str(new_value))
class PCDSSymbolBase(QWidget, PyDMPrimitiveWidget, ContentLocation): """ Base class to be used for all PCDS Symbols. Parameters ---------- parent : QWidget The parent widget for this symbol. """ EXPERT_OPHYD_CLASS = "" Q_ENUMS(ContentLocation) ContentLocation = ContentLocation def __init__(self, parent=None, **kwargs): super(PCDSSymbolBase, self).__init__(parent=parent, **kwargs) self._expert_display = None self.interlock = None self._channels_prefix = None self._rotate_icon = False self._show_icon = True self._show_status_tooltip = True self._icon_size = -1 self._icon = None self._expert_ophyd_class = self.EXPERT_OPHYD_CLASS or "" self.interlock = QFrame(self) self.interlock.setObjectName("interlock") self.interlock.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.controls_frame = QFrame(self) self.controls_frame.setObjectName("controls") self.controls_frame.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) self.setLayout(QVBoxLayout()) self.layout().setSpacing(0) self.layout().setContentsMargins(0, 0, 0, 0) self.layout().addWidget(self.interlock) if not hasattr(self, '_controls_location'): self._controls_location = ContentLocation.Bottom self.setup_icon() self.assemble_layout() self.update_status_tooltip() def sizeHint(self): """ Suggested initial size for the widget. Returns ------- size : QSize """ return QSize(200, 200) @Property(ContentLocation) def controlsLocation(self): """ Property controlling where the controls frame will be displayed. Returns ------- location : ContentLocation """ return self._controls_location @controlsLocation.setter def controlsLocation(self, location): """ Property controlling where the controls frame will be displayed. Parameters ---------- location : ContentLocation """ if location != self._controls_location: self._controls_location = location self.assemble_layout() @Property(str) def channelsPrefix(self): """ The prefix to be used when composing the channels for each of the elements of the symbol widget. The prefix must include the protocol as well. E.g.: ca://VALVE Returns ------- str """ return self._channels_prefix @channelsPrefix.setter def channelsPrefix(self, prefix): """ The prefix to be used when composing the channels for each of the elements of the symbol widget. The prefix must include the protocol as well. E.g.: ca://VALVE Parameters ---------- prefix : str The prefix to be used for the channels. """ if prefix != self._channels_prefix: self._channels_prefix = prefix self.destroy_channels() self.create_channels() @property def icon(self): return self._icon @icon.setter def icon(self, icon): if self._icon != icon: self._icon = icon self.setup_icon() self.iconSize = self.iconSize self.assemble_layout() @Property(bool) def showIcon(self): """ Whether or not to show the symbol icon when rendering the widget. Returns ------- bool """ return self._show_icon @showIcon.setter def showIcon(self, value): """ Whether or not to show the symbol icon when rendering the widget. Parameters ---------- value : bool Shows the Icon if True, hides it otherwise. """ if value != self._show_icon: self._show_icon = value if self.icon: self.icon.setVisible(self._show_icon) self.assemble_layout() @Property(bool) def showStatusTooltip(self): """ Whether or not to show a detailed status tooltip including the state of the widget components such as Interlock, Error, State and more. Returns ------- bool """ return self._show_status_tooltip @showStatusTooltip.setter def showStatusTooltip(self, value): """ Whether or not to show a detailed status tooltip including the state of the widget components such as Interlock, Error, State and more. Parameters ---------- value : bool Displays the tooltip if True. """ if value != self._show_status_tooltip: self._show_status_tooltip = value @Property(int) def iconSize(self): """ The size of the icon in pixels. Returns ------- int """ return self._icon_size @iconSize.setter def iconSize(self, size): """ The size of the icon in pixels. Parameters ---------- size : int A value > 0 will constrain the size of the icon to the defined value. If the value is <= 0 it will expand to fill the space available. """ if not self.icon: return if size <= 0: size = - 1 min_size = 1 max_size = 999999 self.icon.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.icon.setMinimumSize(min_size, min_size) self.icon.setMaximumSize(max_size, max_size) else: self.icon.setFixedSize(size, size) self.icon.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self._icon_size = size self.icon.update() @Property(bool) def rotateIcon(self): """ Rotate the icon 90 degrees clockwise Returns ------- rotate : bool """ return self._rotate_icon @rotateIcon.setter def rotateIcon(self, rotate): """ Rotate the icon 90 degrees clockwise Parameters ---------- rotate : bool """ self._rotate_icon = rotate angle = 90 if self._rotate_icon else 0 if self.icon: self.icon.rotation = angle @Property(str) def expertOphydClass(self): """ The full qualified name of the Ophyd class to be used for the Expert screen to be generated using Typhos. Returns ------- str """ klass = self._expert_ophyd_class if isinstance(klass, type): return f"{klass.__module__}.{klass.__name__}" return klass @expertOphydClass.setter def expertOphydClass(self, klass): """ The full qualified name of the Ophyd class to be used for the Expert screen to be generated using Typhos. Parameters ---------- klass : bool """ if self.ophydClass != klass: self._expert_ophyd_class = klass def paintEvent(self, evt): """ Paint events are sent to widgets that need to update themselves, for instance when part of a widget is exposed because a covering widget was moved. This method handles the painting with parameters from the stylesheet. Parameters ---------- evt : QPaintEvent """ painter = QPainter(self) opt = QStyleOption() opt.initFrom(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) painter.setRenderHint(QPainter.Antialiasing) super(PCDSSymbolBase, self).paintEvent(evt) def clear(self): """ Remove all inner widgets from the interlock frame layout. """ if not self.interlock: return layout = self.interlock.layout() if layout is None: return while layout.count() != 0: item = layout.itemAt(0) if item is not None: layout.removeItem(item) # Trick to remove the existing layout by re-parenting it in an # empty widget. QWidget().setLayout(self.interlock.layout()) def assemble_layout(self): """ Assembles the widget's inner layout depending on the ContentLocation and other configurations set. """ if not self.interlock: return self.clear() # (Layout, items) widget_map = { ContentLocation.Hidden: (QVBoxLayout, [self.icon]), ContentLocation.Top: (QVBoxLayout, [self.controls_frame, self.icon]), ContentLocation.Bottom: (QVBoxLayout, [self.icon, self.controls_frame]), ContentLocation.Left: (QHBoxLayout, [self.controls_frame, self.icon]), ContentLocation.Right: (QHBoxLayout, [self.icon, self.controls_frame]), } layout = widget_map[self._controls_location][0]() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.interlock.setLayout(layout) widgets = widget_map[self._controls_location][1] # Hide the controls box if they are not going to be included in layout controls_visible = self._controls_location != ContentLocation.Hidden self.controls_frame.setVisible(controls_visible) for widget in widgets: if widget is None: continue # Each widget is in a separate layout to help with expansion rules box_layout = QHBoxLayout() box_layout.addWidget(widget) layout.addLayout(box_layout) def setup_icon(self): if not self.icon: return self.icon.setMinimumSize(16, 16) self.icon.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.icon.setVisible(self._show_icon) self.iconSize = 32 if hasattr(self.icon, 'clicked'): self.icon.clicked.connect(self._handle_icon_click) def _handle_icon_click(self): if not self.channelsPrefix: logger.error('No channel prefix specified.' 'Cannot proceed with opening expert screen for %s.', self.__class__.__name__) return if self._expert_display is not None: logger.debug('Bringing existing display to front.') self._expert_display.show() self._expert_display.raise_() return prefix = remove_protocol(self.channelsPrefix) klass = self.expertOphydClass if not klass: logger.error('No ophydClass specified for pcdswidgets %s', self.__class__.__name__) return name = prefix.replace(':', '_') try: import typhos except ImportError: logger.error('Typhos not installed. Cannot create display.') return kwargs = {"name": name, "prefix": prefix} display = typhos.TyphosDeviceDisplay.from_class(klass, **kwargs) self._expert_display = display display.destroyed.connect(self._cleanup_expert_display) if display: display.show() def _cleanup_expert_display(self, *args, **kwargs): self._expert_display = None def status_tooltip(self): """ Assemble and returns the status tooltip for the symbol. Returns ------- str """ status = "" if hasattr(self, 'NAME'): status = self.NAME if status: status += os.linesep status += "PV Prefix: {}".format(self.channelsPrefix) return status def destroy_channels(self): """ Method invoked when the channels associated with the widget must be destroyed. """ for v in self.__dict__.values(): if isinstance(v, PyDMChannel): v.disconnect() def create_channels(self): """ Method invoked when the channels associated with the widget must be created. This method must be implemented on the subclasses and mixins as needed. By default this method does nothing. """ pass def update_stylesheet(self): """ Invoke the stylesheet update process on the widget and child widgets to reflect changes on the properties. """ refresh_style(self) def update_status_tooltip(self): """ Set the tooltip on the symbol to the content of status_tooltip. """ self.setToolTip(self.status_tooltip())
class PyDMTemplateRepeater(QFrame, PyDMPrimitiveWidget, LayoutType): """ PyDMTemplateRepeater takes a .ui file with macro variables as a template, and a JSON file (or a list of dictionaries) with a list of values to use to fill in the macro variables, then creates a layout with one instance of the template for each item in the list. It can be very convenient if you have displays that repeat the same set of widgets over and over - for instance, if you have a standard set of controls for a magnet, and want to build a display with a list of controls for every magnet, the Template Repeater lets you do that with a minimum amount of work: just build a template for a single magnet, and a JSON list with the data that describes all of the magnets. Parameters ---------- parent : optional The parent of this widget. """ Q_ENUMS(LayoutType) LayoutType = LayoutType def __init__(self, parent=None): QFrame.__init__(self, parent) PyDMPrimitiveWidget.__init__(self) self._template_filename = "" self._count_shown_in_designer = 1 self._data_source = "" self._data = [] self._cached_template = None self._layout_type = LayoutType.Vertical self.app = QApplication.instance() self.rebuild() @Property(LayoutType) def layoutType(self): """ The layout type to use. Returns ------- LayoutType """ return self._layout_type @layoutType.setter def layoutType(self, new_type): """ The layout type to use. Options are: - **Vertical**: Instances of the template are laid out vertically, in rows. - **Horizontal**: Instances of the template are laid out horizontally, in columns. - **Flow**: Instances of the template are laid out horizontally until they reach the edge of the template, at which point they "wrap" into a new row. Parameters ---------- new_type : LayoutType """ if new_type != self._layout_type: self._layout_type = new_type self.rebuild() @Property(int) def countShownInDesigner(self): """ The number of instances to show in Qt Designer. This property has no effect outside of Designer. Returns ------- int """ return self._count_shown_in_designer @countShownInDesigner.setter def countShownInDesigner(self, new_count): """ The number of instances to show in Qt Designer. This property has no effect outside of Designer. Parameters ---------- new_count : int """ if not is_qt_designer(): return try: new_count = int(new_count) except ValueError: logger.exception( "Couldn't convert {} to integer.".format(new_count)) return new_count = max(new_count, 0) if new_count != self._count_shown_in_designer: self._count_shown_in_designer = new_count self.rebuild() @Property(str) def templateFilename(self): """ The path to the .ui file to use as a template. Returns ------- str """ return self._template_filename @templateFilename.setter def templateFilename(self, new_filename): """ The path to the .ui file to use as a template. Parameters ---------- new_filename : str """ if new_filename != self._template_filename: self._template_filename = new_filename self._cached_template = None if self._template_filename: self.rebuild() else: self.clear() @Property(str) def dataSource(self): """ The path to the JSON file to fill in each instance of the template. Returns ------- str """ return self._data_source @dataSource.setter def dataSource(self, data_source): """ Sets the path to the JSON file to fill in each instance of the template. For example, if you build a template that contains two macro variables, ${NAME} and ${UNIT}, your JSON file should be a list of dictionaries, each with keys for NAME and UNIT, like this: [{"NAME": "First Device", "UNIT": 1}, {"NAME": "Second Device", "UNIT": 2}] Parameters ------- data_source : str """ if data_source != self._data_source: self._data_source = data_source if self._data_source: try: # Expand user (~ or ~user) and environment variables. fname = os.path.expanduser( os.path.expandvars(self._data_source)) if is_pydm_app(): # If we're running this inside the PyDM app, we can # make sure the path is relative to the currently loaded # display (.ui or .py file). fname = self.app.get_path(fname) with open(fname) as f: self.data = json.load(f) except IOError as e: self.data = [] else: self.clear() def open_template_file(self, variables=None): """ Opens the widget specified in the templateFilename property. Parameters ---------- variables : dict A dictionary of macro variables to apply when loading, in addition to all the macros specified on the template repeater widget. Returns ------- display : QWidget """ if not variables: variables = {} # Expand user (~ or ~user) and environment variables. fname = os.path.expanduser(os.path.expandvars(self.templateFilename)) if is_pydm_app(): if not self._cached_template: self._cached_template = self.app.open_template(fname) return self.app.widget_from_template(self._cached_template, variables) else: try: f = macro.substitute_in_file(fname, variables) return uic.loadUi(f) except Exception as e: logger.exception("Exception while opening template file.") return None def rebuild(self): """ Clear out all existing widgets, and populate the list using the template file and data source.""" self.clear() if (not self.templateFilename) or (not self.data): return self.setUpdatesEnabled(False) layout_class = layout_class_for_type[self.layoutType] if type(self.layout()) != layout_class: if self.layout() is not None: # Trick to remove the existing layout by re-parenting it in an empty widget. QWidget().setLayout(self.layout()) l = layout_class(self) self.setLayout(l) with pydm.data_plugins.connection_queue(defer_connections=True): for i, variables in enumerate(self.data): if is_qt_designer() and i > self.countShownInDesigner - 1: break w = self.open_template_file(variables) if w is None: w = QLabel() w.setText( "No Template Loaded. Data: {}".format(variables)) w.setParent(self) self.layout().addWidget(w) self.setUpdatesEnabled(True) pydm.data_plugins.establish_queued_connections() def clear(self): """ Clear out any existing instances of the template inside the widget.""" if not self.layout(): return while self.layout().count() > 0: item = self.layout().takeAt(0) item.widget().deleteLater() del item def count(self): if not self.layout(): return 0 return self.layout().count() @property def data(self): """ The dictionary used by the widget to fill in each instance of the template. This property will be overwritten if the user changes the dataSource property. """ return self._data @data.setter def data(self, new_data): """ Sets the dictionary used by the widget to fill in each instance of the template. This property will be overwritten if the user changes the dataSource property. After setting this property, `rebuild` is automatically called to refresh the widget. """ self._data = new_data self.rebuild()
class SiriusLabel(QLabel, TextFormatter, PyDMWidget, DisplayFormat): """Sirius Label.""" Q_ENUMS(DisplayFormat) DisplayFormat = DisplayFormat DisplayFormat.Time = 6 """ A QLabel 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. """ def __init__(self, parent=None, init_channel=None, **kws): """Init.""" QLabel.__init__(self, parent, **kws) PyDMWidget.__init__(self, init_channel=init_channel) self.app = QApplication.instance() self.setTextFormat(Qt.PlainText) self.setTextInteractionFlags(Qt.NoTextInteraction) self.setText("PyDMLabel") self._display_format_type = self.DisplayFormat.Default self._string_encoding = "utf_8" self._conv = 1 if is_pydm_app(): self._string_encoding = self.app.get_string_encoding() @Property(DisplayFormat) def displayFormat(self): """Display Format.""" return self._display_format_type @displayFormat.setter def displayFormat(self, new_type): if self._display_format_type != new_type: self._display_format_type = new_type # Trigger the update of display format self.value_changed(self.value) def update_format_string(self): """ Reconstruct the format string to be used when representing the output value. Returns ------- format_string : str The format string to be used including or not the precision and unit """ self.format_string = "{}" if isinstance(self.value, (int, float)): self.format_string = "{:." + str(self.precision) + "f}" if self._show_units and self._unit != "": unt_opt = units.find_unit_options(self._unit) if unt_opt: unt_si = [un for un in unt_opt if units.find_unit(un) == 1][0] self._conv = units.convert(self._unit, unt_si) else: self._conv = 1 unt_si = self._unit self.format_string += " {}" + "{}".format(unt_si) return self.format_string def value_changed(self, new_value): """ Callback invoked when the Channel value is changed. Sets the value of new_value accordingly at the Label. Parameters ---------- new_value : str, int, float, bool or np.ndarray The new value from the channel. The type depends on the channel. """ super(SiriusLabel, self).value_changed(new_value) # If it is a DiaplayFormat.Time, parse with siriuspy.clientarch.Time if self._display_format_type == self.DisplayFormat.Time: time = _Time(int(new_value)).time().isoformat() \ if new_value is not None else '' self.setText(time) return new_value = parse_value_for_display( value=new_value, precision=self.precision, display_format_type=self._display_format_type, string_encoding=self._string_encoding, widget=self) # If the value is a string, just display it as-is, no formatting # needed. if isinstance(new_value, str): self.setText(new_value) return # If the value is an enum, display the appropriate enum string for # the value. if self.enum_strings and isinstance(new_value, (int, float)): try: self.setText(self.enum_strings[int(new_value)]) except IndexError: self.setText(f'Index Overflow [{new_value}]') return # If the value is a number (float or int), display it using a # format string if necessary. if isinstance(new_value, (int, float)): if self._show_units and self._unit != '': new_value *= self._conv sc, prf = func.siScale(new_value) self.setText(self.format_string.format(sc * new_value, prf)) else: self.setText(self.format_string.format(new_value)) return # If you made it this far, just turn whatever the heck the value # is into a string and display it. self.setText(str(new_value))
class PyDMLineEdit(QLineEdit, TextFormatter, PyDMWritableWidget, DisplayFormat): Q_ENUMS(DisplayFormat) DisplayFormat = DisplayFormat """ A QLineEdit (writable text field) with support for Channels and more from PyDM. This widget offers an unit conversion menu when users Right Click into it. Parameters ---------- parent : QWidget The parent widget for the Label init_channel : str, optional The channel to be used by the widget. """ def __init__(self, parent=None, init_channel=None): QLineEdit.__init__(self, parent) PyDMWritableWidget.__init__(self, init_channel=init_channel) self.app = QApplication.instance() self._display = None self._scale = 1 self.returnPressed.connect(self.send_value) self.unitMenu = QMenu('Convert Units', self) self.create_unit_options() self._display_format_type = self.DisplayFormat.Default self._string_encoding = "utf_8" if utilities.is_pydm_app(): self._string_encoding = self.app.get_string_encoding() @Property(DisplayFormat) def displayFormat(self): return self._display_format_type @displayFormat.setter def displayFormat(self, new_type): if self._display_format_type != new_type: self._display_format_type = new_type # Trigger the update of display format self.value_changed(self.value) def value_changed(self, new_val): """ Receive and update the PyDMLineEdit for a new channel value The actual value of the input is saved as well as the type received. This also resets the PyDMLineEdit display text using :meth:`.set_display` Parameters ---------- value: str, float or int The new value of the channel """ super(PyDMLineEdit, self).value_changed(new_val) self.set_display() def send_value(self): """ Emit a :attr:`send_value_signal` to update channel value. The text is cleaned of all units, user-formatting and scale values before being sent back to the channel. This function is attached the ReturnPressed signal of the PyDMLineEdit """ send_value = str(self.text()) # Clean text of unit string if self._show_units and self._unit and self._unit in send_value: send_value = send_value[:-len(self._unit)].strip() try: if self.channeltype not in [str, np.ndarray]: scale = self._scale if scale is None or scale == 0: scale = 1.0 if self._display_format_type in [DisplayFormat.Default, DisplayFormat.String]: if self.channeltype == float: num_value = locale.atof(send_value) else: num_value = self.channeltype(send_value) scale = self.channeltype(scale) elif self._display_format_type == DisplayFormat.Hex: num_value = int(send_value, 16) elif self._display_format_type == DisplayFormat.Binary: num_value = int(send_value, 2) elif self._display_format_type in [DisplayFormat.Exponential, DisplayFormat.Decimal]: num_value = locale.atof(send_value) num_value = num_value / scale self.send_value_signal[self.channeltype].emit(num_value) elif self.channeltype == np.ndarray: # Arrays will be in the [1.2 3.4 22.214] format if self._display_format_type == DisplayFormat.String: self.send_value_signal[str].emit(send_value) else: arr_value = list(filter(None, send_value.replace("[", "").replace("]", "").split(" "))) arr_value = np.array(arr_value, dtype=self.subtype) self.send_value_signal[np.ndarray].emit(arr_value) else: # Channel Type is String # Lets just send what we have after all self.send_value_signal[str].emit(send_value) except ValueError: logger.exception("Error trying to set data '{0}' with type '{1}' and format '{2}' at widget '{3}'." .format(self.text(), self.channeltype, self._display_format_type, self.objectName())) self.clearFocus() self.set_display() def write_access_changed(self, new_write_access): """ Change the PyDMLineEdit to read only if write access is denied """ super(PyDMLineEdit, self).write_access_changed(new_write_access) self.setReadOnly(not new_write_access) def unit_changed(self, new_unit): """ Accept a unit to display with a channel's value The unit may or may not be displayed based on the :attr:`showUnits` attribute. Receiving a new value for the unit causes the display to reset. """ super(PyDMLineEdit, self).unit_changed(new_unit) self._scale = 1 self.create_unit_options() def create_unit_options(self): """ Create the menu for displaying possible unit values The menu is filled with possible unit conversions based on the current PyDMLineEdit. If either the unit is not found in the by the :func:`utilities.find_unit_options` function, or, the :attr:`.showUnits` attribute is set to False, the menu will tell the user that there are no available conversions """ self.unitMenu.clear() units = utilities.find_unit_options(self._unit) if units and self._show_units: for choice in units: self.unitMenu.addAction(choice, partial( self.apply_conversion, choice ) ) else: self.unitMenu.addAction('No Unit Conversions found') def apply_conversion(self, unit): """ Convert the current unit to a different one This function will attempt to find a scalar to convert the current unit type to the desired one and reset the display with the new conversion. Parameters ---------- unit : str String name of desired units """ if not self._unit: logger.warning("Warning: Attempting to convert PyDMLineEdit unit, but no initial units supplied.") return None scale = utilities.convert(str(self._unit), unit) if scale: self._scale = scale * float(self._scale) self._unit = unit self.update_format_string() self.clearFocus() self.set_display() else: logging.warning("Warning: Attempting to convert PyDMLineEdit unit, but '{0}' can not be converted to '{1}'." .format(self._unit, unit)) def widget_ctx_menu(self): """ Fetch the Widget specific context menu which will be populated with additional tools by `assemble_tools_menu`. Returns ------- QMenu or None If the return of this method is None a new QMenu will be created by `assemble_tools_menu`. """ menu = self.createStandardContextMenu() menu.addSeparator() menu.addMenu(self.unitMenu) return menu def set_display(self): """ Set the text display of the PyDMLineEdit. The original value given by the PV is converted to a text entry based on the current settings for scale value, precision, a user-defined format, and the current units. If the user is currently entering a value in the PyDMLineEdit the text will not be changed. """ if self.value is None: return if self.hasFocus(): return new_value = self.value if self._display_format_type in [DisplayFormat.Default, DisplayFormat.Decimal, DisplayFormat.Exponential, DisplayFormat.Hex, DisplayFormat.Binary]: if self.channeltype not in (str, np.ndarray): try: new_value *= self.channeltype(self._scale) except TypeError: logger.error("Cannot convert the value '{0}', for channel '{1}', to type '{2}'. ".format( self._scale, self._channel, self.channeltype)) new_value = parse_value_for_display(value=new_value, precision=self.precision, display_format_type=self._display_format_type, string_encoding=self._string_encoding, widget=self) if type(new_value) in str_types: self._display = new_value else: self._display = str(new_value) if self._display_format_type == DisplayFormat.Default: if isinstance(new_value, (int, float)): self._display = str(self.format_string.format(new_value)) self.setText(self._display) return if self._show_units: self._display = "{} {}".format(self._display, self._unit) self.setText(self._display) def focusOutEvent(self, event): """ Overwrites the function called when a user leaves a PyDMLineEdit without pressing return. Resets the value of the text field to the current channel value. """ if self._display is not None: self.setText(self._display) super(PyDMLineEdit, self).focusOutEvent(event)
class AppImageTreeModel(QAbstractItemModel, AppImageTreeModelRoles): Q_ENUMS(AppImageTreeModelRoles) appInterfaceChanged = Signal() def __init__(self, parent=None): super(AppImageTreeModel, self).__init__(parent) self._app_image = None self._data = AppImageData() self._app_interface = None self._watched = [] @Property(AppInterface, notify=appInterfaceChanged) def appInterface(self): return self._app_interface @appInterface.setter def appInterface(self, new_interface): if new_interface == self._app_interface: return old_interface = self._app_interface self._app_interface = new_interface self.appInterfaceChanged.emit() if old_interface: old_interface.appImageChanged.disconnect(self.refresh) old_interface.itemUpdated.disconnect(self._on_item_updated) if new_interface: new_interface.appImageChanged.connect(self.refresh) new_interface.itemUpdated.connect(self._on_item_updated) self.refresh() def _on_item_updated(self, key): levels = key.split('/') item = self._data for level in levels: item = item.get(level, None) if item is None: return if item.parent is None: row = 0 else: row = list(item.parent.values()).index(item) index = self.createIndex(row, 0, item) self.dataChanged.emit(index, index) @Slot() def refresh(self): if not self._app_interface: logger.warning('cannot refresh without an appInterface') return self.beginResetModel() self._data = self._app_interface.app_image self.endResetModel() def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return None item = index.internalPointer() switch = { Qt.DisplayRole: lambda: item.name, self.NameRole: lambda: item.name, self.ValueRole: lambda: value_to_str(item.value), self.WatchRole: lambda: item.watch, self.KeyRole: lambda: item.key, } data = switch.get(role, lambda: None)() return data def setData(self, index, value, role=Qt.EditRole): item = index.internalPointer() if role in (Qt.EditRole, self.NameRole): item.name = value changed = True elif role == self.ValueRole: new_value = str_to_value(value, item.value) if new_value is None: logger.error(f'Cannot convert value {item.key} {value}') changed = False else: item.value = new_value self._app_interface.setValue(item.key, new_value) changed = True elif role == self.WatchRole: if value: self._app_interface.append_watched(item) else: self._app_interface.remove_watched(item) changed = True else: changed = False if changed: self.dataChanged.emit(index, index, [role]) return changed def roleNames(self): return self.role_names() def flags(self, index): if not index.isValid(): return Qt.NoItemFlags flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable item = index.internalPointer() if not index.parent().isValid() and item.name not in ('proclog', 'eproclist'): flags |= Qt.ItemIsTristate elif len(item) == 0: flags |= Qt.ItemIsEditable elif index.parent().isValid(): flags |= Qt.ItemIsTristate return flags def headerData(self, _section, orientation, role=Qt.DisplayRole): if not orientation == Qt.Horizontal: return None return self.role_names().get(role, '').title() def _get_item(self, index): if index and index.isValid(): item = index.internalPointer() if item: return item return self._data def index(self, row, column, parent=QModelIndex()): if not self.hasIndex(row, column, parent): return QModelIndex() parent_item = self._get_item(parent) try: child_item = list(parent_item.values())[row] except IndexError: return QModelIndex() else: return self.createIndex(row, column, child_item) def parent(self, index): if not index.isValid(): return QModelIndex() child_item = index.internalPointer() parent_item = child_item.parent if parent_item == self._data: return QModelIndex() row = list(parent_item.parent.values()).index(parent_item) return self.createIndex(row, 0, parent_item) def columnCount(self, _parent=QModelIndex()): return len(self.role_names()) def rowCount(self, parent=QModelIndex()): if parent.column() > 0: return 0 if not parent.isValid(): parent_item = self._data else: parent_item = parent.internalPointer() return len(parent_item) if parent_item else 0