Source code for pydm.widgets.related_display_button

import copy
import os
import logging
import warnings
from functools import partial
import hashlib
from qtpy.QtWidgets import QPushButton, QMenu, QAction, QMessageBox, QInputDialog, QLineEdit, QWidget, QStyle
from qtpy.QtGui import QCursor, QIcon, QMouseEvent, QColor
from qtpy.QtCore import Slot, Property, Qt, QSize, QPoint
from qtpy import QtDesigner
from .base import PyDMWidget, only_if_channel_set
from ..utilities import IconFont, find_file, is_pydm_app
from ..utilities.macro import parse_macro_string
from ..utilities.stylesheet import merge_widget_stylesheet
from ..display import load_file, ScreenTarget
from typing import Optional, List

logger = logging.getLogger(__name__)

_relatedDisplayRuleProperties = {"Text": ["setText", str], "Filenames": ["filenames", list]}


[docs]class PyDMRelatedDisplayButton(QPushButton, PyDMWidget, new_properties=_relatedDisplayRuleProperties): """ A QPushButton capable of opening a new Display at the same of at a new window. Parameters ---------- parent : QWidget, optional The parent widget for the related display button filename : str, optional The file to be opened init_channel : str, optional The channel to be used by the widget """ # Constants for determining where to open the display. EXISTING_WINDOW = 0 NEW_WINDOW = 1 def __init__( self, parent: Optional[QWidget] = None, filename: str = None, init_channel: Optional[str] = None ) -> None: QPushButton.__init__(self, parent) PyDMWidget.__init__(self, init_channel=init_channel) self.mouseReleaseEvent = self.push_button_release_event self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) self.iconFont = IconFont() self._icon = self.iconFont.icon("file") self.setIconSize(QSize(16, 16)) self.setIcon(self._icon) self._filenames = [filename] if filename is not None else [] self._titles = [] self._macros = [] self.num_additional_items = 0 self._shift_key_was_down = False self.setCursor(QCursor(self._icon.pixmap(16, 16))) self._display_menu_items = None self._display_filename = "" self._macro_string = None self._open_in_new_window = False self.open_in_new_window_action = QAction("Open in New Window", self) self.open_in_new_window_action.triggered.connect(self.handle_open_new_window_action) self._show_icon = True self._menu_needs_rebuild = True self._password_protected = False self._password = "" self._protected_password = "" self._follow_symlinks = False # Standard icons (which come with the qt install, and work cross-platform), # and icons from the "Font Awesome" icon set (https://fontawesome.com/) # can not be set with a widget's "icon" property in designer, only in python. # so we provide our own property to specify standard icons and set them with python in the prop's setter. self._pydm_icon_name = "" # The color of "Font Awesome" icons can be set, # but standard icons are already colored and can not be set. self._pydm_icon_color = QColor(90, 90, 90)
[docs] @only_if_channel_set def check_enable_state(self) -> None: """ override parent method, so this widget does not get disabled when the pv disconnects. This method adds a Tool Tip with the reason why it is disabled. """ status = self._connected tooltip = self.restore_original_tooltip() if not status: if tooltip != "": tooltip += "\n" tooltip += "Alarm PV is disconnected." tooltip += "\n" tooltip += self.get_address() self.setToolTip(tooltip)
@Property(str) def PyDMIcon(self) -> str: """ Name of icon to be set from Qt provided standard icons or from the fontawesome icon-set. See "enum QStyle::StandardPixmap" in Qt's QStyle documentation for full list of usable standard icons. See https://fontawesome.com/icons?d=gallery for list of usable fontawesome icons. Returns ------- str """ return self._pydm_icon_name @PyDMIcon.setter def PyDMIcon(self, value: str) -> None: """ Name of icon to be set from Qt provided standard icons or from the "Font Awesome" icon-set. See "enum QStyle::StandardPixmap" in Qt's QStyle documentation for full list of usable standard icons. See https://fontawesome.com/icons?d=gallery for list of usable "Font Awesome" icons. Parameters ---------- value : str """ if self._pydm_icon_name == value: return # We don't know if user is trying to use a standard icon or an icon from "Font Awesome", # so 1st try to create a Font Awesome one, which hits exception if icon name is not valid. try: icon_f = IconFont() i = icon_f.icon(value, color=self._pydm_icon_color) self.setIcon(i) except Exception: icon = getattr(QStyle, value, None) if icon: self.setIcon(self.style().standardIcon(icon)) self._pydm_icon_name = value @Property(QColor) def PyDMIconColor(self) -> QColor: """ The color of the icon (color is only applied if using icon from the "Font Awesome" set) Returns ------- QColor """ return self._pydm_icon_color @PyDMIconColor.setter def PyDMIconColor(self, state_color: QColor) -> None: """ The color of the icon (color is only applied if using icon from the "Font Awesome" set) Parameters ---------- new_color : QColor """ if state_color != self._pydm_icon_color: self._pydm_icon_color = state_color # apply the new color try: icon_f = IconFont() i = icon_f.icon(self._pydm_icon_name, color=self._pydm_icon_color) self.setIcon(i) except Exception: return @Property("QStringList") def filenames(self) -> List[str]: return self._filenames @filenames.setter def filenames(self, val: List[str]) -> None: self._filenames = val self._menu_needs_rebuild = True @Property("QStringList") def titles(self) -> List[str]: return self._titles @titles.setter def titles(self, val: List[str]) -> None: self._titles = val self._menu_needs_rebuild = True def _get_items(self): """ Aggregate file entry information. Yields ------ item : dict Containing filename, title, and macros/empy macros Only containing valid entries or nothing """ for i, filename in enumerate(self.filenames): if not filename: continue item = {"filename": filename} if i >= len(self.titles): item["title"] = filename else: item["title"] = self.titles[i] if i < len(self.macros): item["macros"] = self.macros[i] else: item["macros"] = "" yield item def _rebuild_menu(self) -> None: if not any(self._filenames): self._filenames = [] if not any(self._titles): self._titles = [] if len(self._filenames) == 0: self.setEnabled(False) if len(self._filenames) <= 1: self.setMenu(None) self._menu_needs_rebuild = False return menu = QMenu(self) self._assemble_menu(menu, target=None) self.setMenu(menu) self._menu_needs_rebuild = False @Property(bool) def showIcon(self) -> bool: """ Whether or not we should show the selected Icon. Returns ------- bool """ return self._show_icon @showIcon.setter def showIcon(self, value: bool) -> None: """ Whether or not we should show the selected Icon. Parameters ---------- value : bool """ if self._show_icon != value: self._show_icon = value if self._show_icon: self.setIcon(self._icon) else: self._icon = self.icon() self.setIcon(QIcon()) @Property(str, designable=False) def displayFilename(self) -> str: """ DEPRECATED: use the 'filenames' property. This property simply returns the first filename from the 'filenames' property. The filename to open Returns ------- str """ if len(self.filenames) == 0: return "" return self.filenames[0] @displayFilename.setter def displayFilename(self, value: str) -> None: """ DEPRECATED: use the 'filenames' property. Any value set to this property is appended to the 'filenames' property, then 'displayFilename' is cleared. Parameters ---------- value : str """ warnings.warn( "'PyDMRelatedDisplayButton.displayFilename' is deprecated, " "use 'PyDMRelatedDisplayButton.filenames' instead." ) if value: if value in self.filenames: return file_list = [value] self.filenames = self.filenames + file_list self._display_filename = "" @Property("QStringList") def macros(self) -> List[str]: """ The macro substitutions to use when launching the display, in JSON object format. Returns ------- list of str """ return self._macros @macros.setter def macros(self, new_macros: List[str]) -> None: """ The macro substitutions to use when launching the display, in JSON object format. Parameters ---------- new_macros : list of str """ # Handle the deprecated form of macros where it was a single string. if isinstance(new_macros, str): new_macros = [new_macros] self._macros = new_macros @Property(bool) def openInNewWindow(self) -> bool: """ If true, the button will open the display in a new window, rather than in the existing window. Returns ------- bool """ return self._open_in_new_window @openInNewWindow.setter def openInNewWindow(self, open_in_new: bool) -> None: """ If true, the button will open the display in a new window, rather than in the existing window. Parameters ---------- open_in_new : bool """ self._open_in_new_window = open_in_new @Property(bool) def passwordProtected(self) -> bool: """ Whether or not this button is password protected. Returns ------- bool ------- """ return self._password_protected @passwordProtected.setter def passwordProtected(self, value: bool) -> None: """ 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) -> str: """ 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: str) -> None: """ 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() # Make sure designer knows it should save the protectedPassword field formWindow = QtDesigner.QDesignerFormWindowInterface.findFormWindow(self) if formWindow: formWindow.cursor().setProperty("protectedPassword", self.protectedPassword) @Property(str) def protectedPassword(self) -> str: """ The encrypted password. Returns ------- str """ return self._protected_password @protectedPassword.setter def protectedPassword(self, value: str) -> None: if self._protected_password != value: self._protected_password = value @Property(bool) def followSymlinks(self) -> bool: """ If True, any symlinks in the path to filename (including the base path of the parent display) will be followed, so that it will always use the canonical path. If False (default), the file will be searched without canonicalizing the path beforehand. Note that it will not work on Windows if you're using a Python version prior to 3.8. Returns ------- bool """ return self._follow_symlinks @followSymlinks.setter def followSymlinks(self, follow_symlinks: bool) -> None: """ If True, any symlinks in the path to filename (including the base path of the parent display) will be followed, so that it will always use the canonical path. If False (default), the file will be searched using the non-canonical path. Note that it will not work on Windows if you're using a Python version prior to 3.8. Parameters ---------- follow_symlinks : bool """ self._follow_symlinks = follow_symlinks
[docs] def mousePressEvent(self, event: QMouseEvent) -> None: if self._menu_needs_rebuild: self._rebuild_menu() if event.button() == Qt.LeftButton and event.modifiers() == Qt.ShiftModifier: self._shift_key_was_down = True else: self._shift_key_was_down = False super(PyDMRelatedDisplayButton, self).mousePressEvent(event)
[docs] def push_button_release_event(self, mouse_event: QMouseEvent) -> None: """ Opens the related display given by `filename`. If the Shift Key is hold it will open in a new window. Called when a mouse button is released. A widget receives mouse release events when it has received the corresponding mouse press event. This means that if the user presses the mouse inside your widget, then drags the mouse somewhere else before releasing the mouse button, your widget receives the release event. Parameters ---------- mouse_event : QMouseEvent """ if mouse_event.button() != Qt.LeftButton: return super(PyDMRelatedDisplayButton, self).mouseReleaseEvent(mouse_event) if self.menu() is not None: return super(PyDMRelatedDisplayButton, self).mouseReleaseEvent(mouse_event) try: for item in self._get_items(): self.open_display(item["filename"], item["macros"], target=None) break except Exception: logger.exception("Failed to open display.") finally: super(PyDMRelatedDisplayButton, self).mouseReleaseEvent(mouse_event)
[docs] def validate_password(self) -> bool: """ 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:", QLineEdit.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
[docs] @Slot() def handle_open_new_window_action(self) -> None: """ Handle the "Open in New Window" action. Returns ------- None. """ for item in self._get_items(): try: self.open_display(item["filename"], item["macros"], target=self.NEW_WINDOW) except Exception: logger.exception("Failed to open display.")
def _assemble_menu(self, menu, target=None): for item in self._get_items(): try: action = menu.addAction(item["title"]) action.triggered.connect(partial(self.open_display, item["filename"], item["macros"], target=target)) except Exception: logger.exception("Failed to open display.")
[docs] @Slot() def open_display(self, filename, macro_string="", target=None): """ Open the configured `filename` with the given `target`. Parameters ---------- target : int PyDMRelatedDisplayButton.EXISTING_WINDOW or 0 will open the file on the same window. PyDMRelatedDisplayButton.NEW_WINDOW or 1 will result on a new process. Returns ------- display : Display The widget that was opened. Useful for testing and debug. """ if not self.validate_password(): return None parent_display = self.find_parent_display() base_path = "" macros = {} if parent_display: parent_file_path = parent_display.loaded_file() if self._follow_symlinks: parent_file_path = os.path.realpath(parent_file_path) base_path = os.path.dirname(parent_file_path) macros = copy.copy(parent_display.macros()) fname = find_file(filename, base_path=base_path, raise_if_not_found=True) widget_macros = parse_macro_string(macro_string) macros.update(widget_macros) screen_target = None if target is self.NEW_WINDOW: screen_target = ScreenTarget.NEW_PROCESS if self._shift_key_was_down: target = self.NEW_WINDOW screen_target = ScreenTarget.NEW_PROCESS if target is None: if self._open_in_new_window: target = self.NEW_WINDOW screen_target = ScreenTarget.NEW_PROCESS else: target = self.EXISTING_WINDOW screen_target = None if is_pydm_app(): if target == self.NEW_WINDOW: return load_file(fname, macros=macros, target=screen_target) else: return self.window().open(fname, macros=macros) else: display = load_file(fname, macros=macros, target=ScreenTarget.DIALOG) # Not a pydm app: need to give our new display proper pydm styling # Usually done in PyDMApplication merge_widget_stylesheet(widget=display) return display
def context_menu(self): try: menu = super(PyDMRelatedDisplayButton, self).context_menu() except Exception: menu = QMenu(self) if len(menu.findChildren(QAction)) > 0: menu.addSeparator() if len(self.filenames) <= 1: menu.addAction(self.open_in_new_window_action) return menu sub_menu = menu.addMenu("Open in New Window") self._assemble_menu(sub_menu, target=self.NEW_WINDOW) return menu @Slot(QPoint) def show_context_menu(self, pos: QPoint) -> None: menu = self.context_menu() menu.exec_(self.mapToGlobal(pos))