import re from PyQt5.QtCore import Qt, QEvent, pyqtSignal from PyQt5.QtGui import QPainter, QPalette, QPixmap from PyQt5.QtWidgets import QAbstractButton, QLineEdit, QBoxLayout, QHBoxLayout, QLabel, QLayout, QSizePolicy, QSpacerItem, QStyle, QStyleOptionFrame, QWidget from blink.resources import Resources from blink.widgets.util import QtDynamicProperty __all__ = ['LineEdit', 'ValidatingLineEdit', 'SearchBox', 'LocationBar'] class SideWidget(QWidget): sizeHintChanged = pyqtSignal() def __init__(self, parent=None): super(SideWidget, self).__init__(parent) def event(self, event): if event.type() == QEvent.LayoutRequest: self.sizeHintChanged.emit() return QWidget.event(self, event) class LineEdit(QLineEdit): inactiveText = QtDynamicProperty('inactiveText', str) widgetSpacing = QtDynamicProperty('widgetSpacing', int) def __init__(self, parent=None, contents=""): super(LineEdit, self).__init__(contents, parent) box_direction = QBoxLayout.RightToLeft if self.isRightToLeft() else QBoxLayout.LeftToRight self.inactiveText = "" self.left_widget = SideWidget(self) self.left_widget.resize(0, 0) self.left_layout = QHBoxLayout(self.left_widget) self.left_layout.setContentsMargins(0, 0, 0, 0) self.left_layout.setDirection(box_direction) self.left_layout.setSizeConstraint(QLayout.SetFixedSize) self.right_widget = SideWidget(self) self.right_widget.resize(0, 0) self.right_layout = QHBoxLayout(self.right_widget) self.right_layout.setContentsMargins(0, 0, 0, 0) self.right_layout.setDirection(box_direction) self.right_layout.addItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Minimum)) self.widgetSpacing = 2 self.left_widget.sizeHintChanged.connect(self._update_text_margins) self.right_widget.sizeHintChanged.connect(self._update_text_margins) @property def left_margin(self): return self.left_widget.sizeHint().width() + 2*self.left_layout.spacing() @property def right_margin(self): return self.right_widget.sizeHint().width() + 2*self.right_layout.spacing() def _update_text_margins(self): self.setTextMargins(self.left_margin, 0, self.right_margin, 0) self._update_side_widget_locations() def _update_side_widget_locations(self): option = QStyleOptionFrame() self.initStyleOption(option) spacing = self.right_layout.spacing() text_rect = self.style().subElementRect(QStyle.SE_LineEditContents, option, self) text_rect.adjust(spacing, 0, -spacing, 0) mid_height = text_rect.center().y() + 1 - (text_rect.height() % 2) # need -1 correction for odd heights -Dan if self.left_layout.count() > 0: left_height = mid_height - self.left_widget.height()/2 left_width = self.left_widget.width() if left_width == 0: left_height = mid_height - self.left_widget.sizeHint().height()/2 self.left_widget.move(text_rect.x(), left_height) text_rect.setX(self.left_margin) text_rect.setY(mid_height - self.right_widget.sizeHint().height()/2.0) text_rect.setHeight(self.right_widget.sizeHint().height()) self.right_widget.setGeometry(text_rect) def event(self, event): event_type = event.type() if event_type == QEvent.LayoutDirectionChange: box_direction = QBoxLayout.RightToLeft if self.isRightToLeft() else QBoxLayout.LeftToRight self.left_layout.setDirection(box_direction) self.right_layout.setDirection(box_direction) elif event_type == QEvent.DynamicPropertyChange: property_name = event.propertyName() if property_name == 'widgetSpacing': self.left_layout.setSpacing(self.widgetSpacing) self.right_layout.setSpacing(self.widgetSpacing) self._update_text_margins() elif property_name == 'inactiveText': self.update() return QLineEdit.event(self, event) def resizeEvent(self, event): self._update_side_widget_locations() QLineEdit.resizeEvent(self, event) def paintEvent(self, event): QLineEdit.paintEvent(self, event) if not self.hasFocus() and not self.text() and self.inactiveText: options = QStyleOptionFrame() self.initStyleOption(options) text_rect = self.style().subElementRect(QStyle.SE_LineEditContents, options, self) text_rect.adjust(self.left_margin+2, 0, -self.right_margin, 0) painter = QPainter(self) painter.setPen(self.palette().brush(QPalette.Disabled, QPalette.Text).color()) painter.drawText(text_rect, Qt.AlignLeft | Qt.AlignVCenter, self.inactiveText) def addHeadWidget(self, widget): if self.isRightToLeft(): self.right_layout.insertWidget(1, widget) else: self.left_layout.addWidget(widget) def addTailWidget(self, widget): if self.isRightToLeft(): self.left_layout.addWidget(widget) else: self.right_layout.insertWidget(1, widget) def removeWidget(self, widget): self.left_layout.removeWidget(widget) self.right_layout.removeWidget(widget) widget.hide() class ValidatingLineEdit(LineEdit): statusChanged = pyqtSignal() def __init__(self, parent=None): super(ValidatingLineEdit, self).__init__(parent) self.invalid_entry_label = QLabel(self) self.invalid_entry_label.setFixedSize(18, 16) self.invalid_entry_label.setPixmap(QPixmap(Resources.get('icons/invalid16.png'))) self.invalid_entry_label.setScaledContents(False) self.invalid_entry_label.setAlignment(Qt.AlignCenter) self.invalid_entry_label.setObjectName('invalid_entry_label') self.invalid_entry_label.hide() self.addTailWidget(self.invalid_entry_label) option = QStyleOptionFrame() self.initStyleOption(option) frame_width = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth, option, self) self.setMinimumHeight(self.invalid_entry_label.minimumHeight() + 2 + 2*frame_width) self.textChanged.connect(self._SH_TextChanged) self.text_correct = True self.text_allowed = True self.exceptions = set() self.regexp = re.compile(r'.*') def _get_regexp(self): return self.__dict__['regexp'] def _set_regexp(self, regexp): self.__dict__['regexp'] = regexp self._validate() regexp = property(_get_regexp, _set_regexp) del _get_regexp, _set_regexp @property def text_valid(self): return self.text_correct and self.text_allowed def _SH_TextChanged(self, text): self._validate() def _validate(self): text = self.text() text_correct = self.regexp.search(text) is not None text_allowed = text not in self.exceptions if self.text_correct != text_correct or self.text_allowed != text_allowed: self.text_correct = text_correct self.text_allowed = text_allowed self.invalid_entry_label.setVisible(not self.text_valid) self.statusChanged.emit() def addException(self, exception): self.exceptions.add(exception) self._validate() def removeException(self, exception): self.exceptions.remove(exception) self._validate() class SearchIcon(QWidget): def __init__(self, parent=None, size=16): super(SearchIcon, self).__init__(parent) self.setFocusPolicy(Qt.NoFocus) self.setVisible(True) self.setMinimumSize(size+2, size+2) pixmap = QPixmap() if pixmap.load(Resources.get("icons/search.svg")): self.icon = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) else: self.icon = None def paintEvent(self, event): painter = QPainter(self) if self.icon is not None: x = (self.width() - self.icon.width()) / 2 y = (self.height() - self.icon.height()) / 2 painter.drawPixmap(x, y, self.icon) class ClearButton(QAbstractButton): def __init__(self, parent=None, size=16): super(ClearButton, self).__init__(parent) self.setCursor(Qt.ArrowCursor) self.setFocusPolicy(Qt.NoFocus) self.setToolTip("Clear") self.setVisible(False) self.setMinimumSize(size+2, size+2) pixmap = QPixmap() if pixmap.load(Resources.get("icons/delete.svg")): self.icon = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) # Use QImage because QPainter using a QPixmap does not support CompositionMode_Multiply -Dan image = self.icon.toImage() painter = QPainter(image) painter.setRenderHint(QPainter.Antialiasing, True) painter.setCompositionMode(QPainter.CompositionMode_Multiply) painter.drawPixmap(0, 0, self.icon) painter.end() self.icon_pressed = QPixmap(image) else: self.icon = self.icon_pressed = None def paintEvent(self, event): painter = QPainter(self) icon = self.icon_pressed if self.isDown() else self.icon if icon is not None: x = (self.width() - icon.width()) / 2 y = (self.height() - icon.height()) / 2 painter.drawPixmap(x, y, icon) else: width = self.width() height = self.height() padding = width / 5 radius = width - 2*padding palette = self.palette() # Mid is darker than Dark. Go figure... -Dan bg_color = palette.color(QPalette.Mid) if self.isDown() else palette.color(QPalette.Dark) fg_color = palette.color(QPalette.Window) # or QPalette.Base for white painter.setRenderHint(QPainter.Antialiasing, True) painter.setBrush(bg_color) painter.setPen(bg_color) painter.drawEllipse(padding, padding, radius, radius) padding = padding * 2 painter.setPen(fg_color) painter.drawLine(padding, padding, width-padding, height-padding) painter.drawLine(padding, height-padding, width-padding, padding) class SearchBox(LineEdit): def __init__(self, parent=None): super(SearchBox, self).__init__(parent=parent) self.search_icon = SearchIcon(self) self.clear_button = ClearButton(self) self.addHeadWidget(self.search_icon) self.addTailWidget(self.clear_button) option = QStyleOptionFrame() self.initStyleOption(option) frame_width = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth, option, self) widgets_height = max(self.search_icon.minimumHeight(), self.clear_button.minimumHeight()) self.setMinimumHeight(widgets_height + 2 + 2*frame_width) self.clear_button.hide() self.clear_button.clicked.connect(self.clear) self.textChanged.connect(self._SH_TextChanged) self.inactiveText = "Search" def keyPressEvent(self, event): if event.key() == Qt.Key_Escape: self.clear() else: super(SearchBox, self).keyPressEvent(event) def _SH_TextChanged(self, text): self.clear_button.setVisible(bool(text)) class LocationBar(LineEdit): locationCleared = pyqtSignal() def __init__(self, parent=None): super(LocationBar, self).__init__(parent=parent) self.clear_button = ClearButton(self) self.addTailWidget(self.clear_button) option = QStyleOptionFrame() self.initStyleOption(option) frame_width = self.style().pixelMetric(QStyle.PM_DefaultFrameWidth, option, self) widgets_height = self.clear_button.minimumHeight() self.setMinimumHeight(widgets_height + 2 + 2*frame_width) self.clear_button.hide() self.clear_button.clicked.connect(self._SH_ClearButtonClicked) self.textChanged.connect(self._SH_TextChanged) def _SH_ClearButtonClicked(self): self.clear() self.locationCleared.emit() def _SH_TextChanged(self, text): self.clear_button.setVisible(bool(text))