pythoncustomfluentfluent-designfluentuiguimodernpyqt5pyqt6pyside2pyside6qtqt5qt6softwareuiwidgetswin11winuiwinui3
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
550 lines
17 KiB
550 lines
17 KiB
# coding: utf-8 |
|
from typing import List, Union |
|
from PyQt5 import QtCore |
|
from PyQt5.QtCore import QSize, Qt, QRectF, pyqtSignal, QPoint, QTimer, QEvent, QAbstractItemModel, pyqtProperty, QModelIndex |
|
from PyQt5.QtGui import QPainter, QPainterPath, QIcon, QColor |
|
from PyQt5.QtWidgets import (QApplication, QAction, QHBoxLayout, QLineEdit, QToolButton, QTextEdit, |
|
QPlainTextEdit, QCompleter, QStyle, QWidget, QTextBrowser) |
|
|
|
|
|
from ...common.style_sheet import FluentStyleSheet, themeColor |
|
from ...common.icon import isDarkTheme, FluentIconBase, drawIcon |
|
from ...common.icon import FluentIcon as FIF |
|
from ...common.font import setFont |
|
from ...common.color import FluentSystemColor, autoFallbackThemeColor |
|
from .tool_tip import ToolTipFilter |
|
from .menu import LineEditMenu, TextEditMenu, RoundMenu, MenuAnimationType, IndicatorMenuItemDelegate |
|
from .scroll_bar import SmoothScrollDelegate |
|
|
|
|
|
class LineEditButton(QToolButton): |
|
""" Line edit button """ |
|
|
|
def __init__(self, icon: Union[str, QIcon, FluentIconBase], parent=None): |
|
super().__init__(parent=parent) |
|
self._icon = icon |
|
self._action = None |
|
self.isPressed = False |
|
self.setFixedSize(31, 23) |
|
self.setIconSize(QSize(10, 10)) |
|
self.setCursor(Qt.PointingHandCursor) |
|
self.setObjectName('lineEditButton') |
|
FluentStyleSheet.LINE_EDIT.apply(self) |
|
|
|
def setAction(self, action: QAction): |
|
self._action = action |
|
self._onActionChanged() |
|
|
|
self.clicked.connect(action.trigger) |
|
action.toggled.connect(self.setChecked) |
|
action.changed.connect(self._onActionChanged) |
|
|
|
self.installEventFilter(ToolTipFilter(self, 700)) |
|
|
|
def _onActionChanged(self): |
|
action = self.action() |
|
self.setIcon(action.icon()) |
|
self.setToolTip(action.toolTip()) |
|
self.setEnabled(action.isEnabled()) |
|
self.setCheckable(action.isCheckable()) |
|
self.setChecked(action.isChecked()) |
|
|
|
def action(self): |
|
return self._action |
|
|
|
def setIcon(self, icon: Union[str, FluentIconBase, QIcon]): |
|
self._icon = icon |
|
self.update() |
|
|
|
def mousePressEvent(self, e): |
|
self.isPressed = True |
|
super().mousePressEvent(e) |
|
|
|
def mouseReleaseEvent(self, e): |
|
self.isPressed = False |
|
super().mouseReleaseEvent(e) |
|
|
|
def paintEvent(self, e): |
|
super().paintEvent(e) |
|
painter = QPainter(self) |
|
painter.setRenderHints(QPainter.Antialiasing | |
|
QPainter.SmoothPixmapTransform) |
|
|
|
iw, ih = self.iconSize().width(), self.iconSize().height() |
|
w, h = self.width(), self.height() |
|
rect = QRectF((w - iw)/2, (h - ih)/2, iw, ih) |
|
|
|
if self.isPressed: |
|
painter.setOpacity(0.7) |
|
|
|
if isDarkTheme(): |
|
drawIcon(self._icon, painter, rect) |
|
else: |
|
drawIcon(self._icon, painter, rect, fill='#656565') |
|
|
|
|
|
class LineEdit(QLineEdit): |
|
""" Line edit """ |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent=parent) |
|
self._isClearButtonEnabled = False |
|
self._completer = None # type: QCompleter |
|
self._completerMenu = None # type: CompleterMenu |
|
self._isError = False |
|
self.lightFocusedBorderColor = QColor() |
|
self.darkFocusedBorderColor = QColor() |
|
|
|
self.leftButtons = [] # type: List[LineEditButton] |
|
self.rightButtons = [] # type: List[LineEditButton] |
|
|
|
self.setProperty("transparent", True) |
|
FluentStyleSheet.LINE_EDIT.apply(self) |
|
self.setFixedHeight(33) |
|
self.setAttribute(Qt.WA_MacShowFocusRect, False) |
|
setFont(self) |
|
|
|
self.hBoxLayout = QHBoxLayout(self) |
|
self.clearButton = LineEditButton(FIF.CLOSE, self) |
|
|
|
self.clearButton.setFixedSize(29, 25) |
|
self.clearButton.hide() |
|
|
|
self.hBoxLayout.setSpacing(3) |
|
self.hBoxLayout.setContentsMargins(4, 4, 4, 4) |
|
self.hBoxLayout.setAlignment(Qt.AlignRight | Qt.AlignVCenter) |
|
self.hBoxLayout.addWidget(self.clearButton, 0, Qt.AlignRight) |
|
|
|
self.clearButton.clicked.connect(self.clear) |
|
self.textChanged.connect(self.__onTextChanged) |
|
self.textEdited.connect(self.__onTextEdited) |
|
|
|
def isError(self): |
|
return self._isError |
|
|
|
def setError(self, isError: bool): |
|
""" set the error status """ |
|
if isError == self.isError(): |
|
return |
|
|
|
self._isError = isError |
|
self.update() |
|
|
|
def setCustomFocusedBorderColor(self, light, dark): |
|
""" set the border color in focused status |
|
|
|
Parameters |
|
---------- |
|
light, dark: str | QColor | Qt.GlobalColor |
|
border color in light/dark theme mode |
|
""" |
|
self.lightFocusedBorderColor = QColor(light) |
|
self.darkFocusedBorderColor = QColor(dark) |
|
self.update() |
|
|
|
def focusedBorderColor(self): |
|
if self.isError(): |
|
return FluentSystemColor.CRITICAL_FOREGROUND.color() |
|
|
|
return autoFallbackThemeColor(self.lightFocusedBorderColor, self.darkFocusedBorderColor) |
|
|
|
def setClearButtonEnabled(self, enable: bool): |
|
self._isClearButtonEnabled = enable |
|
self._adjustTextMargins() |
|
|
|
def isClearButtonEnabled(self) -> bool: |
|
return self._isClearButtonEnabled |
|
|
|
def setCompleter(self, completer: QCompleter): |
|
self._completer = completer |
|
|
|
def completer(self): |
|
return self._completer |
|
|
|
def addAction(self, action: QAction, position=QLineEdit.ActionPosition.TrailingPosition): |
|
QWidget.addAction(self, action) |
|
|
|
button = LineEditButton(action.icon()) |
|
button.setAction(action) |
|
button.setFixedWidth(29) |
|
|
|
if position == QLineEdit.ActionPosition.LeadingPosition: |
|
self.hBoxLayout.insertWidget(len(self.leftButtons), button, 0, Qt.AlignLeading) |
|
if not self.leftButtons: |
|
self.hBoxLayout.insertStretch(1, 1) |
|
|
|
self.leftButtons.append(button) |
|
else: |
|
self.rightButtons.append(button) |
|
self.hBoxLayout.addWidget(button, 0, Qt.AlignRight) |
|
|
|
self._adjustTextMargins() |
|
|
|
def addActions(self, actions, position=QLineEdit.ActionPosition.TrailingPosition): |
|
for action in actions: |
|
self.addAction(action, position) |
|
|
|
def _adjustTextMargins(self): |
|
left = len(self.leftButtons) * 30 |
|
right = len(self.rightButtons) * 30 + 28 * self.isClearButtonEnabled() |
|
m = self.textMargins() |
|
self.setTextMargins(left, m.top(), right, m.bottom()) |
|
|
|
def focusOutEvent(self, e): |
|
super().focusOutEvent(e) |
|
self.clearButton.hide() |
|
|
|
def focusInEvent(self, e): |
|
super().focusInEvent(e) |
|
if self.isClearButtonEnabled(): |
|
self.clearButton.setVisible(bool(self.text())) |
|
|
|
def __onTextChanged(self, text): |
|
""" text changed slot """ |
|
if self.isClearButtonEnabled(): |
|
self.clearButton.setVisible(bool(text) and self.hasFocus()) |
|
|
|
def __onTextEdited(self, text): |
|
if not self.completer(): |
|
return |
|
|
|
if self.text(): |
|
QTimer.singleShot(50, self._showCompleterMenu) |
|
elif self._completerMenu: |
|
self._completerMenu.close() |
|
|
|
def setCompleterMenu(self, menu): |
|
""" set completer menu |
|
|
|
Parameters |
|
---------- |
|
menu: CompleterMenu |
|
completer menu |
|
""" |
|
menu.activated.connect(self._completer.activated) |
|
menu.indexActivated.connect(lambda idx: self._completer.activated[QModelIndex].emit(idx)) |
|
self._completerMenu = menu |
|
|
|
def _showCompleterMenu(self): |
|
if not self.completer() or not self.text(): |
|
return |
|
|
|
# create menu |
|
if not self._completerMenu: |
|
self.setCompleterMenu(CompleterMenu(self)) |
|
|
|
# add menu items |
|
self.completer().setCompletionPrefix(self.text()) |
|
changed = self._completerMenu.setCompletion(self.completer().completionModel(), self.completer().completionColumn()) |
|
self._completerMenu.setMaxVisibleItems(self.completer().maxVisibleItems()) |
|
|
|
# show menu |
|
if changed: |
|
self._completerMenu.popup() |
|
|
|
def contextMenuEvent(self, e): |
|
menu = LineEditMenu(self) |
|
menu.exec_(e.globalPos()) |
|
|
|
def paintEvent(self, e): |
|
super().paintEvent(e) |
|
if not self.hasFocus(): |
|
return |
|
|
|
painter = QPainter(self) |
|
painter.setRenderHints(QPainter.Antialiasing) |
|
painter.setPen(Qt.NoPen) |
|
|
|
m = self.contentsMargins() |
|
path = QPainterPath() |
|
w, h = self.width()-m.left()-m.right(), self.height() |
|
path.addRoundedRect(QRectF(m.left(), h-10, w, 10), 5, 5) |
|
|
|
rectPath = QPainterPath() |
|
rectPath.addRect(m.left(), h-10, w, 8) |
|
path = path.subtracted(rectPath) |
|
|
|
painter.fillPath(path, self.focusedBorderColor()) |
|
|
|
|
|
class CompleterMenu(RoundMenu): |
|
""" Completer menu """ |
|
|
|
activated = pyqtSignal(str) |
|
indexActivated = pyqtSignal(QModelIndex) |
|
|
|
def __init__(self, lineEdit: LineEdit): |
|
super().__init__() |
|
self.items = [] |
|
self.indexes = [] |
|
self.lineEdit = lineEdit |
|
|
|
self.view.setViewportMargins(0, 2, 0, 6) |
|
self.view.setObjectName('completerListWidget') |
|
self.view.setItemDelegate(IndicatorMenuItemDelegate()) |
|
self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) |
|
|
|
self.installEventFilter(self) |
|
self.setItemHeight(33) |
|
|
|
def setCompletion(self, model: QAbstractItemModel, column=0): |
|
""" set the completion model """ |
|
items = [] |
|
self.indexes.clear() |
|
for i in range(model.rowCount()): |
|
items.append(model.data(model.index(i, column))) |
|
self.indexes.append(model.index(i, column)) |
|
|
|
if self.items == items and self.isVisible(): |
|
return False |
|
|
|
self.setItems(items) |
|
return True |
|
|
|
def setItems(self, items: List[str]): |
|
""" set completion items """ |
|
self.view.clear() |
|
|
|
self.items = items |
|
self.view.addItems(items) |
|
|
|
for i in range(self.view.count()): |
|
item = self.view.item(i) |
|
item.setSizeHint(QSize(1, self.itemHeight)) |
|
|
|
def _onItemClicked(self, item): |
|
self._hideMenu(False) |
|
self._onCompletionItemSelected(item.text(), self.view.row(item)) |
|
|
|
def eventFilter(self, obj, e: QEvent): |
|
if e.type() != QEvent.KeyPress: |
|
return super().eventFilter(obj, e) |
|
|
|
# redirect input to line edit |
|
self.lineEdit.event(e) |
|
self.view.event(e) |
|
|
|
if e.key() == Qt.Key_Escape: |
|
self.close() |
|
if e.key() in [Qt.Key_Enter, Qt.Key_Return] and self.view.currentRow() >= 0: |
|
self._onCompletionItemSelected(self.view.currentItem().text(), self.view.currentRow()) |
|
self.close() |
|
|
|
return super().eventFilter(obj, e) |
|
|
|
def _onCompletionItemSelected(self, text, row): |
|
self.lineEdit.setText(text) |
|
self.activated.emit(text) |
|
|
|
if 0 <= row < len(self.indexes): |
|
self.indexActivated.emit(self.indexes[row]) |
|
|
|
def popup(self): |
|
""" show menu """ |
|
if not self.items: |
|
return self.close() |
|
|
|
# adjust menu size |
|
p = self.lineEdit |
|
if self.view.width() < p.width(): |
|
self.view.setMinimumWidth(p.width()) |
|
self.adjustSize() |
|
|
|
# determine the animation type by choosing the maximum height of view |
|
x = -self.width()//2 + self.layout().contentsMargins().left() + p.width()//2 |
|
y = p.height() - self.layout().contentsMargins().top() + 2 |
|
pd = p.mapToGlobal(QPoint(x, y)) |
|
hd = self.view.heightForAnimation(pd, MenuAnimationType.FADE_IN_DROP_DOWN) |
|
|
|
pu = p.mapToGlobal(QPoint(x, 7)) |
|
hu = self.view.heightForAnimation(pu, MenuAnimationType.FADE_IN_PULL_UP) |
|
|
|
if hd >= hu: |
|
pos = pd |
|
aniType = MenuAnimationType.FADE_IN_DROP_DOWN |
|
else: |
|
pos = pu |
|
aniType = MenuAnimationType.FADE_IN_PULL_UP |
|
|
|
self.view.adjustSize(pos, aniType) |
|
|
|
# update border style |
|
self.view.setProperty('dropDown', aniType == MenuAnimationType.FADE_IN_DROP_DOWN) |
|
self.view.setStyle(QApplication.style()) |
|
self.view.update() |
|
|
|
self.adjustSize() |
|
self.exec(pos, aniType=aniType) |
|
|
|
# remove the focus of menu |
|
self.view.setFocusPolicy(Qt.NoFocus) |
|
self.setFocusPolicy(Qt.NoFocus) |
|
p.setFocus() |
|
|
|
|
|
class SearchLineEdit(LineEdit): |
|
""" Search line edit """ |
|
|
|
searchSignal = pyqtSignal(str) |
|
clearSignal = pyqtSignal() |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent) |
|
self.searchButton = LineEditButton(FIF.SEARCH, self) |
|
|
|
self.hBoxLayout.addWidget(self.searchButton, 0, Qt.AlignRight) |
|
self.setClearButtonEnabled(True) |
|
self.setTextMargins(0, 0, 59, 0) |
|
|
|
self.searchButton.clicked.connect(self.search) |
|
self.clearButton.clicked.connect(self.clearSignal) |
|
|
|
def search(self): |
|
""" emit search signal """ |
|
text = self.text().strip() |
|
if text: |
|
self.searchSignal.emit(text) |
|
else: |
|
self.clearSignal.emit() |
|
|
|
def setClearButtonEnabled(self, enable: bool): |
|
self._isClearButtonEnabled = enable |
|
self.setTextMargins(0, 0, 28*enable+30, 0) |
|
|
|
|
|
class EditLayer(QWidget): |
|
""" Edit layer """ |
|
|
|
def __init__(self, parent): |
|
super().__init__(parent=parent) |
|
self.setAttribute(Qt.WA_TransparentForMouseEvents) |
|
parent.installEventFilter(self) |
|
|
|
def eventFilter(self, obj, e): |
|
if obj is self.parent() and e.type() == QEvent.Resize: |
|
self.resize(e.size()) |
|
|
|
return super().eventFilter(obj, e) |
|
|
|
def paintEvent(self, e): |
|
if not self.parent().hasFocus(): |
|
return |
|
|
|
painter = QPainter(self) |
|
painter.setRenderHints(QPainter.Antialiasing) |
|
painter.setPen(Qt.NoPen) |
|
|
|
m = self.contentsMargins() |
|
path = QPainterPath() |
|
w, h = self.width()-m.left()-m.right(), self.height() |
|
path.addRoundedRect(QRectF(m.left(), h-10, w, 10), 5, 5) |
|
|
|
rectPath = QPainterPath() |
|
rectPath.addRect(m.left(), h-10, w, 7.5) |
|
path = path.subtracted(rectPath) |
|
|
|
painter.fillPath(path, themeColor()) |
|
|
|
|
|
class TextEdit(QTextEdit): |
|
""" Text edit """ |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent=parent) |
|
self.layer = EditLayer(self) |
|
self.scrollDelegate = SmoothScrollDelegate(self) |
|
FluentStyleSheet.LINE_EDIT.apply(self) |
|
setFont(self) |
|
|
|
def contextMenuEvent(self, e): |
|
menu = TextEditMenu(self) |
|
menu.exec_(e.globalPos()) |
|
|
|
|
|
class PlainTextEdit(QPlainTextEdit): |
|
""" Plain text edit """ |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent=parent) |
|
self.layer = EditLayer(self) |
|
self.scrollDelegate = SmoothScrollDelegate(self) |
|
FluentStyleSheet.LINE_EDIT.apply(self) |
|
setFont(self) |
|
|
|
def contextMenuEvent(self, e): |
|
menu = TextEditMenu(self) |
|
menu.exec_(e.globalPos()) |
|
|
|
|
|
class TextBrowser(QTextBrowser): |
|
""" Text browser """ |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent) |
|
self.layer = EditLayer(self) |
|
self.scrollDelegate = SmoothScrollDelegate(self) |
|
FluentStyleSheet.LINE_EDIT.apply(self) |
|
setFont(self) |
|
|
|
def contextMenuEvent(self, e): |
|
menu = TextEditMenu(self) |
|
menu.exec_(e.globalPos()) |
|
|
|
|
|
class PasswordLineEdit(LineEdit): |
|
""" Password line edit """ |
|
|
|
def __init__(self, parent=None): |
|
super().__init__(parent) |
|
self.viewButton = LineEditButton(FIF.VIEW, self) |
|
|
|
self.setEchoMode(QLineEdit.Password) |
|
self.setContextMenuPolicy(Qt.NoContextMenu) |
|
self.hBoxLayout.addWidget(self.viewButton, 0, Qt.AlignRight) |
|
self.setClearButtonEnabled(False) |
|
|
|
self.viewButton.installEventFilter(self) |
|
self.viewButton.setIconSize(QSize(13, 13)) |
|
self.viewButton.setFixedSize(29, 25) |
|
|
|
def setPasswordVisible(self, isVisible: bool): |
|
""" set the visibility of password """ |
|
if isVisible: |
|
self.setEchoMode(QLineEdit.Normal) |
|
else: |
|
self.setEchoMode(QLineEdit.Password) |
|
|
|
def isPasswordVisible(self): |
|
return self.echoMode() == QLineEdit.Normal |
|
|
|
def setClearButtonEnabled(self, enable: bool): |
|
self._isClearButtonEnabled = enable |
|
|
|
if self.viewButton.isHidden(): |
|
self.setTextMargins(0, 0, 28*enable, 0) |
|
else: |
|
self.setTextMargins(0, 0, 28*enable + 30, 0) |
|
|
|
def setViewPasswordButtonVisible(self, isVisible: bool): |
|
""" set the visibility of view password button """ |
|
self.viewButton.setVisible(isVisible) |
|
|
|
def eventFilter(self, obj, e): |
|
if obj is not self.viewButton or not self.isEnabled(): |
|
return super().eventFilter(obj, e) |
|
|
|
if e.type() == QEvent.MouseButtonPress: |
|
self.setPasswordVisible(True) |
|
elif e.type() == QEvent.MouseButtonRelease: |
|
self.setPasswordVisible(False) |
|
|
|
return super().eventFilter(obj, e) |
|
|
|
def inputMethodQuery(self, query: Qt.InputMethodQuery): |
|
# Disable IME for PasswordLineEdit |
|
if query == Qt.InputMethodQuery.ImEnabled: |
|
return False |
|
else: |
|
return super().inputMethodQuery(query) |
|
|
|
passwordVisible = pyqtProperty(bool, isPasswordVisible, setPasswordVisible) |