diff --git a/examples/Gallery for siui/components/page_refactor/page_refactor.py b/examples/Gallery for siui/components/page_refactor/page_refactor.py index 3a047a8..c1418d8 100644 --- a/examples/Gallery for siui/components/page_refactor/page_refactor.py +++ b/examples/Gallery for siui/components/page_refactor/page_refactor.py @@ -3,10 +3,12 @@ from PyQt5.QtCore import QPointF, Qt from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QBoxLayout, QButtonGroup, QWidget, QSizePolicy +from PyQt5.QtWidgets import QBoxLayout, QButtonGroup, QSizePolicy, QWidget from siui.components import SiDenseHContainer, SiDenseVContainer, SiTitledWidgetGroup from siui.components.button import ( + SiCapsuleButton, + SiCheckBoxRefactor, SiFlatButton, SiFlatButtonWithIndicator, SiLongPressButtonRefactor, @@ -16,11 +18,12 @@ SiRadioButtonWithAvatar, SiRadioButtonWithDescription, SiSwitchRefactor, - SiToggleButtonRefactor, SiCheckBoxRefactor, SiCapsuleButton, + SiToggleButtonRefactor, ) from siui.components.chart import SiTrendChart +from siui.components.combobox_ import SiCapsuleComboBox from siui.components.container import SiDenseContainer, SiTriSectionPanelCard, SiTriSectionRowCard -from siui.components.editbox import SiLabeledLineEdit, SiDoubleSpinBox, SiCapsuleLineEdit, SiSpinBox +from siui.components.editbox import SiCapsuleLineEdit, SiDoubleSpinBox, SiLabeledLineEdit, SiSpinBox from siui.components.label import SiLinearIndicator, SiLinearPartitionIndicator from siui.components.page import SiPage from siui.components.progress_bar_ import SiProgressBarRefactor @@ -47,6 +50,7 @@ def createDenseContainer(parent: SiDenseContainer, side: Qt.Edges = Qt.LeftEdge | Qt.TopEdge) -> SiDenseContainer: container = SiDenseContainer(parent) container.layout().setDirection(direction) + container.layout().setSpacing(12) container.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) try: yield container @@ -71,6 +75,23 @@ def __init__(self, *args, **kwargs): with self.titled_widgets_group as group: group.addTitle("按钮") + with createPanelCard(group, "下拉选择器") as card: + with createDenseContainer(card.body(), QBoxLayout.LeftToRight) as container: + combo_editable = SiCapsuleComboBox(self) + combo_editable.setTitle("可编辑选择器") + combo_editable.setMinimumHeight(36) + combo_editable.setEditable(True) + combo_editable.addItems(["Python", "C++", "JavaScript"]) + + combo_not_editable = SiCapsuleComboBox(self) + combo_not_editable.setTitle("只读选择器") + combo_not_editable.setMinimumHeight(36) + combo_not_editable.setEditable(False) + combo_not_editable.addItems(["Python", "C++", "JavaScript"]) + + container.addWidget(combo_editable) + container.addWidget(combo_not_editable) + with createPanelCard(group, "胶囊按钮") as card: with createDenseContainer(card.body(), QBoxLayout.LeftToRight) as container: capsule_button_1 = SiCapsuleButton(self) @@ -572,30 +593,31 @@ def test_func_2(): self.linear_edit_box = SiCapsuleLineEdit(self) self.linear_edit_box.resize(560, 36) + self.linear_edit_box.setTitleWidthMode(SiCapsuleLineEdit.TitleWidthMode.Ratio) self.linear_edit_box.setTitle("Repository Name") self.linear_edit_box.setText("PyQt-SiliconUI") self.linear_edit_box2 = SiCapsuleLineEdit(self) self.linear_edit_box2.resize(560, 36) + self.linear_edit_box2.setTitleWidthMode(SiCapsuleLineEdit.TitleWidthMode.Ratio) self.linear_edit_box2.setTitle("Owner") self.linear_edit_box2.setText("ChinaIceF") self.linear_edit_box3 = SiCapsuleLineEdit(self) self.linear_edit_box3.resize(560, 36) + self.linear_edit_box3.setTitleWidthMode(SiCapsuleLineEdit.TitleWidthMode.Ratio) self.linear_edit_box3.setTitle("Description") self.linear_edit_box3.setText("A powerful and artistic UI library based on PyQt5") - self.check_button = SiPushButtonRefactor(self) + self.check_button = SiFlatButton(self) self.check_button.setText("确定") - self.check_button.clicked.connect(self.linear_edit_box.validate) - self.check_button.clicked.connect(self.linear_edit_box2.validate) - self.check_button.clicked.connect(self.linear_edit_box3.validate) + self.linear_edit_box3.addWidgetToRight(self.check_button) self.editbox.body().setSpacing(11) self.editbox.body().addWidget(self.linear_edit_box) self.editbox.body().addWidget(self.linear_edit_box2) self.editbox.body().addWidget(self.linear_edit_box3) - self.editbox.body().addWidget(self.check_button) + # self.editbox.body().addWidget(self.check_button) self.editbox.body().addPlaceholder(12) self.editbox.adjustSize() diff --git a/siui/components/button.py b/siui/components/button.py index c327f79..7cbbcde 100644 --- a/siui/components/button.py +++ b/siui/components/button.py @@ -1744,7 +1744,7 @@ def _initStyle(self) -> None: self._description_label.setWordWrap(True) def _initToolTipManager(self) -> None: - self._tooltip_manager = WidgetTooltipRedirectEventFilter(toolTipWindow()) + self._tooltip_manager = WidgetTooltipRedirectEventFilter() self.installEventFilter(self._tooltip_manager) def _initScaleManager(self) -> None: @@ -2049,7 +2049,7 @@ def __init__(self, parent: T_WidgetParent = None) -> None: self.toggled.connect(self._onToggled) def _initToolTipManager(self) -> None: - self._tooltip_manager = WidgetTooltipRedirectEventFilter(toolTipWindow()) + self._tooltip_manager = WidgetTooltipRedirectEventFilter() self.installEventFilter(self._tooltip_manager) def _initScaleManager(self) -> None: diff --git a/siui/components/combobox_.py b/siui/components/combobox_.py new file mode 100644 index 0000000..9a65c68 --- /dev/null +++ b/siui/components/combobox_.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +from PyQt5.QtCore import QEvent, QMargins, QObject, QPoint, QRect, QRectF, QSize, Qt +from PyQt5.QtGui import QColor, QIcon, QKeySequence, QMouseEvent, QPainter, QPainterPath +from PyQt5.QtWidgets import QAction, QActionGroup, QComboBox, QHBoxLayout, QLabel, QSpacerItem, QWidget + +from siui.components.button import SiFlatButton, SiTransparentButton +from siui.components.editbox import SiCapsuleLineEdit +from siui.components.label import SiRoundPixmapWidget +from siui.components.menu_ import ActionItemsWidgetStyleData, SiMenuItemWidget, SiRoundedMenu +from siui.core import SiGlobal, createPainter +from siui.core.event_filter import WidgetTooltipAcceptEventFilter +from siui.gui import SiFont +from siui.typing import T_WidgetParent + + +class CheckedIndicatorStyleData: + indicator_color = QColor("#C88CD4") + + +class ComboboxItemWidgetCheckedIndicator(QWidget): + def __init__(self, action: QAction, parent=None): + super().__init__(parent) + + self._action = action + self._is_checked = self._action.isChecked() + + self.style_data = CheckedIndicatorStyleData() + + def updateAction(self) -> None: + self._is_checked = self._action.isChecked() + self.update() + + def _drawIndicatorRect(self, painter: QPainter, rect: QRect) -> None: + if self._is_checked is False: + return + path = QPainterPath() + path.addRoundedRect(QRectF(rect), 2, 2) + painter.setPen(Qt.NoPen) + painter.setBrush(self.style_data.indicator_color) + painter.drawPath(path) + + def paintEvent(self, a0): + rect = self.rect() + + with createPainter(self) as painter: + self._drawIndicatorRect(painter, rect) + + +class ComboboxItemWidget(SiMenuItemWidget): + def __init__(self, action: QAction, parent=None): + super().__init__(action, parent) + + self.setFixedHeight(32) + + self._action = action + self.style_data = ActionItemsWidgetStyleData() + + self._checked_indicator = ComboboxItemWidgetCheckedIndicator(action, self) + self._icon_widget = SiRoundPixmapWidget(self) + self._name_label = QLabel(self) + self._shortcut_widget = QLabel(self) + self._button = SiTransparentButton(self) + + self._applyAction(action) + self._initWidgets() + self._initLayout() + self._initTooltipAcceptFilter() + + self._button.clicked.connect(self._onButtonClicked) + + def _initWidgets(self) -> None: + sd = self.style_data + + self._checked_indicator.setFixedSize(4, 16) + + self._shortcut_widget.setStyleSheet( + f"color: {sd.shortcut_text_color.name()};" + f"background-color: {sd.shortcut_background_color.name()};" + "padding: 1px 6px 1px 6px;" + "margin: 1px 8px 0px 8px;" + "border-radius: 4px;" + ) + + self._icon_widget.setPixmap(self._action.icon().pixmap(64, 64)) + self._icon_widget.setFixedSize(32, 32) + self._icon_widget.setVisualSize(QSize(18, 18)) + self._icon_widget.setVisualSizeEnabled(True) + + self._name_label.setFont(SiFont.getFont(size=14)) + self._name_label.setText(self._action.text()) + self._name_label.setAlignment(Qt.AlignVCenter) + self._name_label.setFixedHeight(32) + self._name_label.setMinimumWidth(32) + self._name_label.setStyleSheet(f""" + QLabel {{ + margin: 0px 8px 1px 0px; + color: {sd.label_text_color_enabled.name(QColor.HexArgb)}; + }} + QLabel:disabled {{ + margin: 0px 8px 1px 0px; + color: {sd.label_text_color_disabled.name(QColor.HexArgb)}; + }} + """) + + self._shortcut_widget.setFont(SiFont.getFont(size=9)) + self._shortcut_widget.setAlignment(Qt.AlignCenter) + self._shortcut_widget.setFixedHeight(18) + + self._button.setBorderRadius(6) + + def _initLayout(self) -> None: + layout = QHBoxLayout(self) + layout.addWidget(self._checked_indicator) + layout.addSpacerItem(QSpacerItem(4, 32)) + layout.addWidget(self._icon_widget) + layout.addSpacerItem(QSpacerItem(4, 32)) + layout.addWidget(self._name_label) + layout.addStretch() + layout.addWidget(self._shortcut_widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self.setLayout(layout) + + def _initTooltipAcceptFilter(self) -> None: + self._tooltip_accept_filter = WidgetTooltipAcceptEventFilter(self) + self.installEventFilter(self._tooltip_accept_filter) + + def _applyAction(self, action: QAction) -> None: + self.addAction(action) + self._updateFromAction(action) + action.changed.connect(lambda: self._updateFromAction(action)) + + def _updateFromAction(self, action: QAction) -> None: + self.setText(action.text()) + self.setIcon(action.icon()) + self.setToolTip(action.toolTip()) + self.setShortcut(action.shortcut()) + self.setEnabled(action.isEnabled()) + + self._checked_indicator.updateAction() + + def _onButtonClicked(self) -> None: + self._action.trigger() + self._button.leave() + self.peek() + self.reachEnd() + + def setText(self, text: str) -> None: + self._name_label.setText(text) + + def setIcon(self, icon: QIcon) -> None: + self._icon_widget.setPixmap(icon.pixmap(64, 64)) + + def setShortcut(self, shortcut: QKeySequence) -> None: + string = shortcut.toString() + self._shortcut_widget.setText(string) + + def setCheckedIndicatorVisible(self, state: bool) -> None: + self._checked_indicator.setVisible(state) + + def setIconVisible(self, state: bool) -> None: + self._icon_widget.setVisible(state) + + def setShortcutVisible(self, state: bool) -> None: + self._shortcut_widget.setVisible(state and not self._action.shortcut().isEmpty()) + + def resizeEvent(self, a0): + super().resizeEvent(a0) + self._button.resize(a0.size()) + + def enterEvent(self, a0): + super().enterEvent(a0) + self._action.hover() + + +class ComboBoxClickEventFilter(QObject): + def __init__(self, combobox: QComboBox, line_edit: SiCapsuleLineEdit, menu: SiRoundedMenu): + super().__init__(line_edit) + self._combobox = combobox + self._line_edit = line_edit + self._menu = menu + + self._is_pressed = False + + def eventFilter(self, obj: QWidget, event: QEvent): + if self._combobox.isEditable(): + return False + + if event.type() == QEvent.Type.MouseButtonPress: + event: QMouseEvent + self._is_pressed = event.button() == Qt.LeftButton + return False + + if event.type() == QEvent.Type.MouseButtonRelease: + if self._is_pressed: + self._combobox.showPopup() + + self._is_pressed = False + return True + + return False + + +class SiCapsuleComboBox(QComboBox): + def __init__(self, parent: T_WidgetParent = None) -> None: + super().__init__(parent) + + self._is_menu_dirty = True + + self._menu = SiRoundedMenu(self) + self._text_to_width_hint: dict[str, int] = {} + self._max_width_hint = -1 + self._title = "untitled" + + self._line_edit = SiCapsuleLineEdit(self) + + self._initStyleSheet() + self._initWidget() + self._initClickEventFilter() + self._initModelSignals() + self._initMenuSignals() + + def _initStyleSheet(self) -> None: + self.setStyleSheet(""" + QComboBox { + border: none; + } + QComboBox::drop-down { + width: 0px; + border: 0px; + } + QComboBox:editable { + padding-right: 0px; + } + """) + + def _initWidget(self) -> None: + button = SiFlatButton(self) + button.setFixedSize(28, 28) + button.setSvgIcon(SiGlobal.siui.iconpack.get("ic_fluent_chevron_down_regular")) + button.clicked.connect(self.showPopup) + + super().setLineEdit(self._line_edit) + self._line_edit.setReadOnly(not self.isEditable()) + self._line_edit.setTitle(self._title) + self._line_edit.addWidgetToRight(button) + self._line_edit.setContextMenuPolicy(Qt.CustomContextMenu) + + def _initClickEventFilter(self) -> None: + self._click_event_filter = ComboBoxClickEventFilter(self, self._line_edit, self._menu) + self._line_edit.installEventFilter(self._click_event_filter) + + def _initModelSignals(self) -> None: + model = self.model() + model.dataChanged.connect(self._onModelChanged) + model.layoutChanged.connect(self._onModelChanged) + model.modelReset.connect(self._onModelChanged) + model.rowsInserted.connect(self._onModelChanged) + model.rowsRemoved.connect(self._onModelChanged) + model.columnsInserted.connect(self._onModelChanged) + model.columnsRemoved.connect(self._onModelChanged) + + def _initMenuSignals(self) -> None: + self._menu.triggered.connect(self._onMenuActionTriggered) + + def _onMenuActionTriggered(self, action: QAction) -> None: + self.setCurrentIndex(action.data()) + + def _onModelChanged(self) -> None: + self._is_menu_dirty = True + self.updateGeometry() + + def _rebuildMenu(self) -> None: + self._menu.clear() + + action_group = QActionGroup(self) + action_group.setExclusionPolicy(QActionGroup.ExclusionPolicy.Exclusive) + + for i in range(self.count()): + text = self.itemText(i) + action = QAction(text) + action.setData(i) + action.setCheckable(True) + action.setChecked(i == self.currentIndex()) + + action_group.addAction(action) + self._menu.addCustomWidget(action, ComboboxItemWidget) + + self._menu.refreshLayout() + + def _calcSizeHintCache(self) -> None: + for i in range(self.count()): + text = self.itemText(i) + if text in self._text_to_width_hint.keys(): + continue + + width_for_text = self._line_edit.widthForText(text) + self._text_to_width_hint.update([(text, width_for_text)]) + + self._max_width_hint = max(list(self._text_to_width_hint.values()) + [0]) + + def sizeHint(self): + if self._is_menu_dirty: + self._rebuildMenu() + self._calcSizeHintCache() + self._is_menu_dirty = False + return QSize(self._max_width_hint, super().sizeHint().height()) + + def minimumSizeHint(self): + min_width = self._line_edit.widthForText("") + 48 + min_height = 36 + return QSize(min_width, min_height) + + def setTitle(self, text: str) -> None: + self._title = text + self._line_edit.setTitle(text) + self.updateGeometry() + + def setEditable(self, editable: bool) -> None: + self._line_edit.setReadOnly(not editable) + + def isEditable(self) -> bool: + return not self._line_edit.isReadOnly() + + def showPopup(self) -> None: + if self._is_menu_dirty: + self._rebuildMenu() + self._calcSizeHintCache() + self._is_menu_dirty = False + + title_width = self._line_edit.titleWidth() + menu_width = self.width() - title_width + self._menu.container().setFixedWidth(menu_width) + + pos = self.rect().bottomLeft() + QPoint(title_width, 0) + self._menu.popup(self.mapToGlobal(pos)) + + def hidePopup(self) -> None: + self._menu.close() + + def paintEvent(self, e): + pass + + def resizeEvent(self, e): + super().resizeEvent(e) + self._line_edit.resize(e.size()) diff --git a/siui/components/container.py b/siui/components/container.py index ffde668..793f276 100644 --- a/siui/components/container.py +++ b/siui/components/container.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import overload from PyQt5.QtCore import QRectF, QSize, Qt from PyQt5.QtGui import QColor, QPainter, QPainterPath, QPixmap @@ -12,6 +13,25 @@ from siui.typing import T_WidgetParent +class SiBoxContainer(QWidget): + LeftToRight = QBoxLayout.LeftToRight + RightToLeft = QBoxLayout.RightToLeft + TopToBottom = QBoxLayout.TopToBottom + BottomToTop = QBoxLayout.BottomToTop + + def __init__(self, + parent: T_WidgetParent = None, + direction: QBoxLayout.Direction = QBoxLayout.LeftToRight) -> None: + super().__init__(parent) + + layout = QBoxLayout(direction) + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + def layout(self) -> QBoxLayout | None: # overloaded for coding hints, has no effect on any impl. + return super().layout() + + class SiDenseContainer(QWidget): LeftToRight = QBoxLayout.LeftToRight RightToLeft = QBoxLayout.RightToLeft diff --git a/siui/components/editbox.py b/siui/components/editbox.py index 1a068fe..1100ac5 100644 --- a/siui/components/editbox.py +++ b/siui/components/editbox.py @@ -1,11 +1,8 @@ from __future__ import annotations import difflib -import random -from dataclasses import dataclass -import numpy -from PyQt5.QtCore import QEvent, QObject, QPoint, QPointF, QRectF, QSize, Qt, pyqtProperty +from PyQt5.QtCore import QMargins, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt, pyqtProperty from PyQt5.QtGui import ( QColor, QDoubleValidator, @@ -14,14 +11,16 @@ QIntValidator, QPainter, QPainterPath, + QTextOption, ) -from PyQt5.QtWidgets import QAction, QApplication, QLineEdit +from PyQt5.QtWidgets import QAction, QApplication, QLineEdit, QWidget from siui.components.button import SiFlatButton -from siui.components.container import SiDenseContainer +from siui.components.container import SiBoxContainer, SiDenseContainer from siui.components.menu_ import SiRoundedMenu -from siui.core import SiGlobal, createPainter, hideToolTip, isToolTipInsideOf, showToolTip +from siui.core import SiGlobal, createPainter from siui.core.animation import SiExpAnimationRefactor +from siui.core.event_filter import WidgetTooltipRedirectEventFilter from siui.gui import SiFont from siui.typing import T_WidgetParent @@ -44,18 +43,37 @@ class LineEditStyleData: class SiCapsuleLineEdit(QLineEdit): + class TitleWidthMode: + Preferred = "Preferred" + Fixed = "Fixed" + Ratio = "Ratio" + class Property: TitleColor = "titleColor" TextIndicatorColor = "textIndicatorColor" TextIndicatorWidth = "textIndicatorWidth" - def __init__(self, parent: T_WidgetParent = None, title: str = "Untitled Edit Box") -> None: + def __init__(self, parent: T_WidgetParent = None, title: str = "Untitled") -> None: super().__init__(parent) + self.setFont(SiFont.getFont(size=14)) + self.style_data = LineEditStyleData() + + self._left_edge_container = SiBoxContainer(self) + self._right_edge_container = SiBoxContainer(self) + self._title_font = SiFont.getFont(size=13) - self._title = title - self._title_width = 160 + self._title_text = title + self._title_text_margins = QMargins(17, 0, 17, 0) + self._title_width_mode = self.TitleWidthMode.Preferred + self._title_fixed_width = 160 + self._title_ratio = 0.25 + + self._in_rect_container_margins = QMargins(8, 0, 8, 0) + self._in_rect_text_margins = QMargins(10, 0, 10, 0) + self._user_text_margins = QMargins(0, 0, 0, 0) + self._title_color = self.style_data.title_color_idle self._text_indi_color = self.style_data.text_indicator_color_idle self._text_indi_width = 0 @@ -70,15 +88,21 @@ def __init__(self, parent: T_WidgetParent = None, title: str = "Untitled Edit Bo self.text_indicator_width_ani = SiExpAnimationRefactor(self, self.Property.TextIndicatorWidth) self.text_indicator_color_ani.init(1/8, 0.01, 0, 0) - self.setFont(SiFont.getFont(size=14)) + self._initWidget() self._initStyleSheet() - self._createCustomMenu() + self._initTooltipRedirectEventFilter() + self._initSignals() + + self._updateTextMargins() + self._createCustomMenu() self.setContextMenuPolicy(Qt.CustomContextMenu) - self.customContextMenuRequested.connect(self._showCustomMenu) - self.textChanged.connect(self._onTextEdited) - self.returnPressed.connect(self._onReturnPressed) + def _initWidget(self) -> None: + self._left_edge_container.layout().setDirection(SiBoxContainer.LeftToRight) + self._left_edge_container.layout().setSpacing(6) + self._right_edge_container.layout().setDirection(SiBoxContainer.RightToLeft) + self._right_edge_container.layout().setSpacing(6) def _initStyleSheet(self) -> None: self.setStyleSheet( @@ -87,12 +111,20 @@ def _initStyleSheet(self) -> None: " background-color: transparent;" f" color: {self.style_data.text_color.name()};" " border: 0px;" - f" padding-left: {self._title_width + 18}px;" - " padding-right: 18px;" - " padding-bottom: 1px;" "}" ) + def _initTooltipRedirectEventFilter(self): + self._tooltip_redirect_event_filter = WidgetTooltipRedirectEventFilter() + self.installEventFilter(self._tooltip_redirect_event_filter) + + def _initSignals(self) -> None: + self.customContextMenuRequested.connect(self._showCustomMenu) + self.textChanged.connect(self._onTextEdited) + self.returnPressed.connect(self._onReturnPressed) + + # region Property + @pyqtProperty(QColor) def titleColor(self): return self._title_color @@ -129,7 +161,10 @@ def textIndicatorWidth(self, value: float): self._text_indi_width = value self.update() + # endregion + def _showCustomMenu(self, pos: QPoint): + print(self.actions()) self.undo_action.setEnabled(self.isUndoAvailable()) self.redo_action.setEnabled(self.isRedoAvailable()) self.cut_action.setEnabled(self.hasSelectedText()) @@ -140,39 +175,32 @@ def _showCustomMenu(self, pos: QPoint): self.menu.popup(self.mapToGlobal(pos)) def _createCustomMenu(self): - self.menu = SiRoundedMenu(self) # 创建菜单 + self.menu = SiRoundedMenu(self) self.undo_action = QAction("撤销", self) self.undo_action.setShortcut("Ctrl+Z") self.undo_action.triggered.connect(self.undo) - # self.addAction(self.undo_action) self.redo_action = QAction("重做", self) self.redo_action.setShortcut("Ctrl+Shift+Z") self.redo_action.triggered.connect(self.redo) - # self.addAction(self.redo_action) self.cut_action = QAction("剪切", self) self.cut_action.setShortcut("Ctrl+X") self.cut_action.triggered.connect(self.cut) - # self.addAction(self.cut_action) self.copy_action = QAction("复制", self) self.copy_action.setShortcut("Ctrl+C") self.copy_action.triggered.connect(self.copy) - # self.addAction(self.copy_action) self.paste_action = QAction("粘贴", self) self.paste_action.setShortcut("Ctrl+V") self.paste_action.triggered.connect(self.paste) - # self.addAction(self.paste_action) self.select_all_action = QAction("全选", self) self.select_all_action.setShortcut("Ctrl+A") self.select_all_action.triggered.connect(self.selectAll) - # self.addAction(self.select_all_action) - # 组装菜单 self.menu.addAction(self.undo_action) self.menu.addAction(self.redo_action) self.menu.addSeparator() @@ -186,23 +214,89 @@ def _createCustomMenu(self): def title(self) -> str: return self._title + def _updateTextMargins(self) -> None: + user_text_margins = self._user_text_margins + in_rect_text_margins = self._in_rect_text_margins + in_rect_container_margins = self._in_rect_container_margins + container_margins = self._calcContainerMargins() + + title_text_margins = self._title_text_margins + title_text_width = self._calcTitleTextWidth() + + out_rect_margins = QMargins() + out_rect_margins.setLeft(title_text_margins.left() + title_text_margins.right() + title_text_width) + + new_margins = user_text_margins + in_rect_text_margins + container_margins + in_rect_container_margins + out_rect_margins + super().setTextMargins(new_margins) + + def _updateContainerGeometry(self) -> None: + margins = self._in_rect_container_margins + text_rect = self._calcTextRect() + available_rect = text_rect.marginsRemoved(margins) + + left_size = self._left_edge_container.sizeHint() + x = available_rect.left() + self._right_edge_container.setGeometry(x, 0, left_size.width(), self.height()) + + right_size = self._right_edge_container.sizeHint() + x = available_rect.right() - right_size.width() + self._right_edge_container.setGeometry(x, 0, right_size.width(), self.height()) + def setTitle(self, title: str) -> None: - self._title = title + self._title_text = title + self._updateTextMargins() + self._updateContainerGeometry() self.update() - def titleWidth(self) -> int: - return self._title_width + def setTitleWidthMode(self, mode) -> None: + self._title_width_mode = mode + self._updateTextMargins() + self._updateContainerGeometry() + self.update() - def setTitleWidth(self, width: int) -> None: - self._title_width = width - self._initStyleSheet() + def setTitleFixedWidth(self, width: int) -> None: + self._title_fixed_width = width + self._updateTextMargins() + self._updateContainerGeometry() self.update() - @staticmethod - def _validationFunc(text: str) -> bool | str: - if text == "": - return "此项不能为空" - return True + def setTitleRatio(self, ratio: float) -> None: + self._title_ratio = ratio + self._updateTextMargins() + self._updateContainerGeometry() + self.update() + + def titleWidth(self) -> int: + return self._calcTitleRect().width() + + def widthForText(self, text: str) -> int: + metrics = QFontMetrics(self.font()) + text_size = QSize(metrics.horizontalAdvance(text) + 8, 0) + grown_size = text_size.grownBy(super().textMargins()) + return grown_size.width() + + def setTextMargins(self, left: int, top: int, right: int, bottom: int) -> None: + self._user_text_margins = QMargins(left, top, right, bottom) + self._updateTextMargins() + + def textMargins(self) -> QMargins: + return self._user_text_margins + + def leftEdgeContainer(self) -> QWidget: + return self._left_edge_container + + def rightEdgeContainer(self) -> QWidget: + return self._right_edge_container + + def addWidgetToLeft(self, widget: QWidget) -> None: + self._left_edge_container.addWidget(widget) + self._updateTextMargins() + self._updateContainerGeometry() + + def addWidgetToRight(self, widget: QWidget) -> None: + self._right_edge_container.layout().addWidget(widget) + self._updateTextMargins() + self._updateContainerGeometry() def notifyInvalidInput(self): self.text_indicator_color_ani.setEndValue(self.style_data.text_indicator_color_error) @@ -212,18 +306,13 @@ def notifyInvalidInput(self): self.text_indicator_width_ani.setEndValue(self.width() - self._title_width - 36) self.text_indicator_width_ani.start() - def validate(self): - result = self._validationFunc(self.text()) - if result is True: - self.setToolTip("") - else: - self.setToolTip(result) - self.notifyInvalidInput() - def _onTextEdited(self, text: str): + margins = self._in_rect_container_margins + self._calcContainerMargins() + self._in_rect_text_margins metric = QFontMetrics(self.font()) - text_rect = QRectF(self._title_width, 0, self.width() - self._title_width, self.height()) - width = min(metric.boundingRect(text).width(), text_rect.width() - 36) + text_rect = self._calcTextRect() + text_bounding_width = metric.boundingRect(text).width() + available_width = text_rect.marginsRemoved(margins).width() + width = min(text_bounding_width, available_width) self.text_indicator_width_ani.setEndValue(width) self.text_indicator_width_ani.start() @@ -244,63 +333,82 @@ def _onReturnPressed(self): target.setFocus() self.clearFocus() - self.validate() - - def _drawTitleBackgroundPath(self, rect: QRectF) -> QPainterPath: - path = QPainterPath() - path.addRoundedRect(rect, 10, 10) - return path + def _calcTitleTextWidth(self) -> int: + if self._title_width_mode == self.TitleWidthMode.Preferred: + metrics = QFontMetrics(self._title_font) + return metrics.horizontalAdvance(self._title_text) - def _drawTitleRect(self, painter: QPainter, rect: QRectF) -> None: - sd = self.style_data - text_rect = QRectF(rect.x() + 17, rect.y(), rect.width(), rect.height() - 1) + elif self._title_width_mode == self.TitleWidthMode.Fixed: + return self._title_fixed_width - painter.setBrush(sd.title_background_color) - painter.drawPath(self._drawTitleBackgroundPath(rect)) - - painter.setPen(self._title_color) - painter.setFont(self._title_font) - painter.drawText(painter.boundingRect(text_rect, Qt.AlignVCenter | Qt.AlignLeft, self._title), self._title) + elif self._title_width_mode == self.TitleWidthMode.Ratio: + return int(self.width() * self._title_ratio) + else: + raise ValueError(f"{self._title_width_mode}") + + def _calcTitleRect(self) -> QRect: + width = self._calcTitleTextWidth() + self._title_text_margins.left() + self._title_text_margins.right() + height = self.height() + self._title_text_margins.top() + self._title_text_margins.bottom() + rect = QRect(0, 0, width, height) + return rect + + def _calcTextRect(self) -> QRect: + rect = self.rect() + shrunk_rect = rect.adjusted(self._calcTitleRect().width(), 0, 0, 0) + return shrunk_rect + + def _calcContainerMargins(self) -> QMargins: + container_margins = QMargins() + container_margins.setLeft(self._left_edge_container.sizeHint().width()) + container_margins.setRight(self._right_edge_container.sizeHint().width()) + return container_margins + + def _drawBodyBackgroundRect(self, painter: QPainter, rect: QRect) -> None: + path = QPainterPath() + path.addRoundedRect(QRectF(rect), 10, 10) painter.setPen(Qt.NoPen) + painter.setBrush(self.style_data.title_background_color) + painter.drawPath(path) - def _drawTextBackgroundPath(self, rect: QRectF) -> QPainterPath: + def _drawEditBackgroundRect(self, painter: QPainter, rect: QRect) -> None: path = QPainterPath() - path.addRoundedRect(rect, 10, 10) - return path + path.addRoundedRect(QRectF(rect), 10, 10) + painter.setPen(Qt.NoPen) + painter.setBrush(self.style_data.text_background_color) + painter.drawPath(path) - def _drawTextIndicatorPath(self, rect: QRectF) -> QPainterPath: - indi_rect = QRectF(rect.x() + 16, rect.y() + 34, self._text_indi_width + 8, 2) - path = QPainterPath() - path.addRoundedRect(indi_rect, 1, 1) - return path + def _drawTitleText(self, painter: QPainter, rect: QRect) -> None: + shrunk_rect = rect.marginsRemoved(self._title_text_margins) + option = QTextOption() + option.setWrapMode(QTextOption.WrapMode.NoWrap) + option.setAlignment(Qt.AlignVCenter | Qt.AlignLeft) + painter.setFont(self._title_font) + painter.setPen(self._title_color) + painter.drawText(QRectF(shrunk_rect), self._title_text, option) - def _drawTextRect(self, painter: QPainter, rect: QRectF) -> None: - sd = self.style_data - painter.setBrush(sd.text_background_color) - painter.drawPath(self._drawTextBackgroundPath(rect)) + def _drawIndicatorRect(self, painter: QPainter, rect: QRect) -> None: + container_margins = self._calcContainerMargins() - painter.setBrush(self._text_indi_color) - painter.drawPath(self._drawTextIndicatorPath(rect)) + shrunk_rect = rect.marginsRemoved(container_margins) + indi_rect = QRect(shrunk_rect.topLeft() + QPoint(16, 34), QSize(self._text_indi_width + 8, 2)) - def event(self, event): - if event.type() == QEvent.ToolTip: - return True # 忽略工具提示事件 - return super().event(event) + path = QPainterPath() + path.addRoundedRect(QRectF(indi_rect), 1, 1) + painter.setPen(Qt.NoPen) + painter.setBrush(self._text_indi_color) + painter.drawPath(path) def paintEvent(self, a0): - title_rect = QRectF(0, 0, self.width(), self.height()) - text_rect = QRectF(self._title_width, 0, self.width() - self._title_width, self.height()) + body_rect = self.rect() + title_rect = self._calcTitleRect() + text_rect = self._calcTextRect() - renderHints = ( - QPainter.RenderHint.SmoothPixmapTransform - | QPainter.RenderHint.TextAntialiasing - | QPainter.RenderHint.Antialiasing - ) - - with createPainter(self, renderHints) as painter: - self._drawTitleRect(painter, title_rect) - self._drawTextRect(painter, text_rect) + with createPainter(self) as painter: + self._drawBodyBackgroundRect(painter, body_rect) + self._drawEditBackgroundRect(painter, text_rect) + self._drawTitleText(painter, title_rect) + self._drawIndicatorRect(painter, text_rect) super().paintEvent(a0) @@ -312,10 +420,6 @@ def focusInEvent(self, a0): self.title_color_ani.start() self._onTextEdited(self.text()) - self.setToolTip("") # clean tooltip once it gets focus. - - if isToolTipInsideOf(self): - hideToolTip(self) def focusOutEvent(self, a0): super().focusOutEvent(a0) @@ -326,14 +430,9 @@ def focusOutEvent(self, a0): self._onTextEdited("") - def enterEvent(self, a0): - super().enterEvent(a0) - showToolTip(self) - - def leaveEvent(self, a0): - super().leaveEvent(a0) - hideToolTip(self) - + def resizeEvent(self, a0): + super().resizeEvent(a0) + self._updateContainerGeometry() class AnimatedCharObject(QObject): diff --git a/siui/components/menu_.py b/siui/components/menu_.py index 16a9b4a..b9f2187 100644 --- a/siui/components/menu_.py +++ b/siui/components/menu_.py @@ -116,11 +116,12 @@ class Type: Section = "Section" Custom = "Custom" - def __init__(self, parent, type_, data): + def __init__(self, parent, type_, action: QAction, cls=None): super().__init__(parent) self.type = type_ - self.data = data + self.action = action + self.cls = cls class SiMenuItemWidget(QWidget): @@ -201,11 +202,11 @@ def _initWidgets(self) -> None: self._name_label.setMinimumWidth(32) self._name_label.setStyleSheet(f""" QLabel {{ - margin: 0px 8px 0px 0px; + margin: 0px 8px 1px 0px; color: {sd.label_text_color_enabled.name(QColor.HexArgb)}; }} QLabel:disabled {{ - margin: 0px 8px 0px 0px; + margin: 0px 8px 1px 0px; color: {sd.label_text_color_disabled.name(QColor.HexArgb)}; }} """) @@ -333,11 +334,11 @@ def _initWidgets(self) -> None: self._name_label.setMinimumWidth(32) self._name_label.setStyleSheet(f""" QLabel {{ - margin: 0px 8px 0px 0px; + margin: 0px 8px 1px 0px; color: {sd.label_text_color_enabled.name(QColor.HexArgb)}; }} QLabel:disabled {{ - margin: 0px 8px 0px 0px; + margin: 0px 8px 1px 0px; color: {sd.label_text_color_disabled.name(QColor.HexArgb)}; }} """) @@ -505,19 +506,19 @@ class SiMenuItemWidgetFactory: @staticmethod def create(item: SiMenuItem, parent=None) -> SiMenuItemWidget: if item.type == SiMenuItem.Type.Action: - return ActionItemWidget(item.data, parent) + return ActionItemWidget(item.action, parent) elif item.type == SiMenuItem.Type.Separator: - return SeparatorItemWidget(item.data, parent) + return SeparatorItemWidget(item.action, parent) elif item.type == SiMenuItem.Type.SubMenu: - return SubmenuItemWidget(item.data, parent) + return SubmenuItemWidget(item.action, parent) elif item.type == SiMenuItem.Type.Section: - return SectionItemWidget(item.data, parent) + return SectionItemWidget(item.action, parent) elif item.type == SiMenuItem.Type.Custom: - action, widget_cls = item.data + action, widget_cls = item.action, item.cls widget = widget_cls(action, parent) widget.setParent(parent) @@ -694,7 +695,7 @@ def _addItem(self, item: SiMenuItem) -> None: self._items.append(item) self._widgets.update([(item, widget)]) - self._action_to_items.update([(item.data, item)]) + self._action_to_items.update([(item.action, item)]) self._is_layout_dirty = True @@ -707,7 +708,7 @@ def _insertItem(self, before: QAction, item: SiMenuItem) -> None: self._items.insert(before_index, item) self._widgets.update([(item, widget)]) - self._action_to_items.update([(item.data, item)]) + self._action_to_items.update([(item.action, item)]) self._is_layout_dirty = True @@ -747,7 +748,7 @@ def addSection(self, *args, **kwargs) -> QAction | None: def addCustomWidget(self, action: QAction, widget_cls: type[SiMenuItemWidget]) -> QAction | None: new_action = super().addAction(action) - item = SiMenuItem(self, SiMenuItem.Type.Custom, [action, widget_cls]) + item = SiMenuItem(self, SiMenuItem.Type.Custom, action, widget_cls) self._addItem(item) return new_action @@ -793,7 +794,7 @@ def insertCustomWidget(self, before: QAction, action: QAction, widget_cls: type[SiMenuItemWidget]) -> QAction | None: new_action = super().addAction(action) - item = SiMenuItem(self, SiMenuItem.Type.Custom, [action, widget_cls]) + item = SiMenuItem(self, SiMenuItem.Type.Custom, action, widget_cls) self._insertItem(before, item) return new_action @@ -810,6 +811,8 @@ def removeAction(self, action: QAction) -> None: super().removeAction(action) item = self._action_to_items.get(action) widget = self._widgets.get(item) + + self._container.layout().removeWidget(widget) widget.reachedEnd.disconnect() widget.deleteLater() @@ -819,8 +822,24 @@ def removeAction(self, action: QAction) -> None: return None + def clear(self) -> None: + super().clear() + for item in self._items: + widget = self._widgets.get(item) + + self._container.layout().removeWidget(widget) + widget.reachedEnd.disconnect() + widget.deleteLater() + + self._widgets.clear() + self._items.clear() + self._action_to_items.clear() + # endregion + def container(self) -> QWidget: + return self._container + def _clearPeekingAction(self) -> None: self._peeking_action = None @@ -832,7 +851,7 @@ def isSubmenu(self) -> bool: def sizeHint(self): screen_rect = QApplication.desktop().availableGeometry() - container_size = self._container.sizeHint() + container_size = self._container.size() expanded_rect = container_size.grownBy(self._margins) width = expanded_rect.width() @@ -865,7 +884,7 @@ def _updateComponentsVisibility(self) -> None: item_in_section = [] continue - action: QAction = item.data + action: QAction = item.action has_checkable |= action.isCheckable() has_icon |= not action.icon().isNull() has_shortcut |= not action.shortcut().isEmpty() diff --git a/siui/components/progress_bar_.py b/siui/components/progress_bar_.py index 4d11f1a..f6e502b 100644 --- a/siui/components/progress_bar_.py +++ b/siui/components/progress_bar_.py @@ -72,7 +72,7 @@ def _initSignal(self) -> None: self.stateChanged.connect(self._onStateChanged) def _initToolTipManager(self) -> None: - self._manager = WidgetTooltipRedirectEventFilter(toolTipWindow()) + self._manager = WidgetTooltipRedirectEventFilter() self.installEventFilter(self._manager) @pyqtProperty(QColor) diff --git a/siui/core/event_filter.py b/siui/core/event_filter.py index af4a08c..a9010f3 100644 --- a/siui/core/event_filter.py +++ b/siui/core/event_filter.py @@ -4,6 +4,7 @@ from PyQt5.QtWidgets import QWidget from siui.components.tooltip import ToolTipWindow +from siui.core import SiGlobal class DebugEventFilter(QObject): @@ -54,11 +55,14 @@ class WidgetTooltipRedirectEventFilter(QObject): """ 忽略原版工具提示,并把工具提示发送到自定义工具提示窗口上,提供操作工具提示窗口的接口 """ - def __init__(self, tooltip_window: ToolTipWindow): - super().__init__() - self._tooltip_window = tooltip_window + def __init__(self, parent: QWidget = None): + super().__init__(parent) + self._tooltip_window = self._initTooltipWindow() self._entered = False + def _initTooltipWindow(self) -> ToolTipWindow: + return SiGlobal.siui.windows.get("TOOL_TIP") + def setTooltip(self, text: str, do_flash: bool = True) -> None: if self._tooltip_window is None: return diff --git a/siui/core/painter.py b/siui/core/painter.py index 4c82144..e125fb6 100644 --- a/siui/core/painter.py +++ b/siui/core/painter.py @@ -1,52 +1,24 @@ from __future__ import annotations import math +from contextlib import contextmanager from functools import lru_cache -from typing import TYPE_CHECKING -from PyQt5.QtCore import QPointF, QRectF, Qt, QPoint -from PyQt5.QtGui import QPainter, QPainterPath, QColor, QLinearGradient - -if TYPE_CHECKING: - from PyQt5.QtGui import QFont, QPaintDevice - - from siui.typing import T_Brush, T_PenStyle, T_RenderHint - - -def createPainter( - paintDevice: QPaintDevice, - renderHint: T_RenderHint = QPainter.RenderHint.Antialiasing, - penStyle: T_PenStyle = Qt.PenStyle.NoPen, - brush: T_Brush = None, - font: QFont | None = None, -) -> QPainter: - """构造并初始化 QPainter 对象 - 应该使用 with 关键字来创建和关闭 QPainter 对象 - - 参数: - - parent: QPaintDevice 的子类实例,通常是 QWidget 或 QImage - - renderHint: 指定渲染提示,默认为 QPainter.RenderHint.Antialiasing 标准抗锯齿 - - penStyle: Qt.PenStyle 类型,指定画笔样式,默认为 Qt.PenStyle.NoPen - - brushColor: 字符串或 QColor 对象,指定画刷颜色,默认不指定 - - font: QFont 对象,指定字体,默认不指定 - - 返回: - QPainter 对象实例 - """ - painter = QPainter(paintDevice) - if renderHint is not None: - painter.setRenderHints(renderHint) - - if penStyle is not None: - painter.setPen(penStyle) - - if brush is not None: - painter.setBrush(brush) - - if font is not None: - painter.setFont(font) - - return painter +from PyQt5.QtCore import QPoint, QPointF, QRectF, Qt +from PyQt5.QtGui import QColor, QLinearGradient, QPaintDevice, QPainter, QPainterPath + + +@contextmanager +def createPainter(device: QPaintDevice, + render_hints: QPainter.RenderHints = QPainter.Antialiasing) -> QPainter: + painter = QPainter(device) + painter.setRenderHints(render_hints) + painter.setPen(Qt.NoPen) + painter.setBrush(Qt.NoBrush) + try: + yield painter + finally: + painter.end() @lru_cache(maxsize=None)