PortProtonQt/portprotonqt/custom_widgets.py
Boris Yumankulov abec9bbef8
Move repo from git to gitea
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-01 15:21:32 +05:00

394 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import numpy as np
from PySide6.QtWidgets import QLabel, QPushButton, QWidget, QLayout, QStyleOption, QLayoutItem
from PySide6.QtCore import Qt, Signal, QRect, QPoint, QSize
from PySide6.QtGui import QFont, QFontMetrics, QPainter
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
"""
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
rect_width: доступная ширина контейнера.
spacing: отступ между элементами.
max_scale: максимальный коэффициент масштабирования (например, 1.2).
Возвращает:
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
total_height: итоговая высота всех рядов.
"""
N = nat_sizes.shape[0]
result = np.zeros((N, 4), dtype=np.int32)
y = 0
i = 0
while i < N:
sum_width = 0
row_max_height = 0
count = 0
j = i
# Подбираем количество элементов для текущего ряда
while j < N:
w = nat_sizes[j, 0]
# Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим
if count > 0 and (sum_width + spacing + w) > rect_width:
break
sum_width += w
count += 1
h = nat_sizes[j, 1]
if h > row_max_height:
row_max_height = h
j += 1
# Доступная ширина ряда с учетом обязательных отступов между элементами
available_width = rect_width - spacing * (count - 1)
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
# Разрешаем увеличение карточек, но не более max_scale
scale = desired_scale if desired_scale < max_scale else max_scale
# Выравниваем по левому краю (offset = 0)
x = 0
for k in range(i, j):
new_w = int(nat_sizes[k, 0] * scale)
new_h = int(nat_sizes[k, 1] * scale)
result[k, 0] = x
result[k, 1] = y
result[k, 2] = new_w
result[k, 3] = new_h
x += new_w + spacing
y += int(row_max_height * scale) + spacing
i = j
return result, y
class FlowLayout(QLayout):
def __init__(self, parent=None):
super().__init__(parent)
self.itemList = []
# Устанавливаем отступы контейнера в 0 и задаем spacing между карточками
self.setContentsMargins(0, 0, 0, 0)
self._spacing = 3 # отступ между карточками
self._max_scale = 1.2 # максимальное увеличение карточек (например, на 20%)
def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item)
def takeAt(self, index: int) -> QLayoutItem:
if 0 <= index < len(self.itemList):
return self.itemList.pop(index)
raise IndexError("Index out of range")
def count(self) -> int:
return len(self.itemList)
def itemAt(self, index: int) -> QLayoutItem | None:
if 0 <= index < len(self.itemList):
return self.itemList[index]
return None
def expandingDirections(self):
return Qt.Orientation(0)
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
return self.doLayout(QRect(0, 0, width, 0), True)
def setGeometry(self, rect):
super().setGeometry(rect)
self.doLayout(rect, False)
def sizeHint(self):
return self.minimumSize()
def minimumSize(self):
size = QSize()
for item in self.itemList:
size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins()
size += QSize(margins.left() + margins.right(),
margins.top() + margins.bottom())
return size
def doLayout(self, rect, testOnly):
N = len(self.itemList)
if N == 0:
return 0
# Собираем натуральные размеры всех элементов в массив NumPy
nat_sizes = np.empty((N, 2), dtype=np.int32)
for i, item in enumerate(self.itemList):
s = item.sizeHint()
nat_sizes[i, 0] = s.width()
nat_sizes[i, 1] = s.height()
# Вычисляем геометрию с учетом spacing и max_scale через numba-функцию
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
if not testOnly:
for i, item in enumerate(self.itemList):
x = geom_array[i, 0] + rect.x()
y = geom_array[i, 1] + rect.y()
w = geom_array[i, 2]
h = geom_array[i, 3]
item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
return total_height
class ClickableLabel(QLabel):
clicked = Signal()
def __init__(self, *args, icon=None, icon_size=16, icon_space=5, **kwargs):
"""
Поддерживаются вызовы:
- ClickableLabel("текст", parent=...) первый аргумент строка,
- ClickableLabel(parent, text="...") если первым аргументом передается родитель.
Аргументы:
icon: QIcon или None иконка, которая будет отрисована вместе с текстом.
icon_size: int размер иконки (ширина и высота).
icon_space: int отступ между иконкой и текстом.
"""
if args and isinstance(args[0], str):
text = args[0]
parent = kwargs.get("parent", None)
super().__init__(text, parent)
elif args and isinstance(args[0], QWidget):
parent = args[0]
text = kwargs.get("text", "")
super().__init__(parent)
self.setText(text)
else:
text = ""
parent = kwargs.get("parent", None)
super().__init__(text, parent)
self._icon = icon
self._icon_size = icon_size
self._icon_space = icon_space
self.setCursor(Qt.CursorShape.PointingHandCursor)
def setIcon(self, icon):
"""Устанавливает иконку и перерисовывает виджет."""
self._icon = icon
self.update()
def icon(self):
"""Возвращает текущую иконку."""
return self._icon
def paintEvent(self, event):
"""Переопределяем отрисовку: рисуем иконку и текст в одном лейбле."""
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.contentsRect()
alignment = self.alignment()
icon_size = self._icon_size
spacing = self._icon_space
icon_rect = QRect()
text_rect = QRect()
text = self.text()
if self._icon:
# Получаем QPixmap нужного размера
pixmap = self._icon.pixmap(icon_size, icon_size)
icon_rect = QRect(0, 0, icon_size, icon_size)
icon_rect.moveTop(rect.top() + (rect.height() - icon_size) // 2)
else:
pixmap = None
fm = QFontMetrics(self.font())
text_width = fm.horizontalAdvance(text)
text_height = fm.height()
total_width = text_width + (icon_size + spacing if pixmap else 0)
if alignment & Qt.AlignmentFlag.AlignHCenter:
x = rect.left() + (rect.width() - total_width) // 2
elif alignment & Qt.AlignmentFlag.AlignRight:
x = rect.right() - total_width
else:
x = rect.left()
y = rect.top() + (rect.height() - text_height) // 2
if pixmap:
icon_rect.moveLeft(x)
text_rect = QRect(x + icon_size + spacing, y, text_width, text_height)
else:
text_rect = QRect(x, y, text_width, text_height)
option = QStyleOption()
option.initFrom(self)
if pixmap:
painter.drawPixmap(icon_rect, pixmap)
self.style().drawItemText(
painter,
text_rect,
alignment,
self.palette(),
self.isEnabled(),
text,
self.foregroundRole(),
)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
event.accept()
else:
super().mousePressEvent(event)
class AutoSizeButton(QPushButton):
def __init__(self, *args, icon=None, icon_size=16,
min_font_size=6, max_font_size=14, padding=20, update_size=True, **kwargs):
if args and isinstance(args[0], str):
text = args[0]
parent = kwargs.get("parent", None)
super().__init__(text, parent)
elif args and isinstance(args[0], QWidget):
parent = args[0]
text = kwargs.get("text", "")
super().__init__(text, parent)
else:
text = ""
parent = kwargs.get("parent", None)
super().__init__(text, parent)
self._icon = icon
self._icon_size = icon_size
self._alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
self._min_font_size = min_font_size
self._max_font_size = max_font_size
self._padding = padding
self._update_size = update_size
self._original_font = self.font()
self._original_text = self.text()
if self._icon:
self.setIcon(self._icon)
self.setIconSize(QSize(self._icon_size, self._icon_size))
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFlat(True)
# Изначально выставляем минимальную ширину
self.setMinimumWidth(50)
self.adjustFontSize()
def setAlignment(self, alignment):
self._alignment = alignment
self.update()
def alignment(self):
return self._alignment
def setText(self, text):
self._original_text = text
if not self._update_size:
super().setText(text)
else:
super().setText(text)
self.adjustFontSize()
def resizeEvent(self, event):
super().resizeEvent(event)
if self._update_size:
self.adjustFontSize()
def adjustFontSize(self):
if not self._original_text:
return
if not self._update_size:
return
# Определяем доступную ширину внутри кнопки
available_width = self.width()
if self._icon:
available_width -= self._icon_size
margins = self.contentsMargins()
available_width -= (margins.left() + margins.right() + self._padding * 2)
font = QFont(self._original_font)
text = self._original_text
# Подбираем максимально возможный размер шрифта, при котором текст укладывается
chosen_size = self._max_font_size
for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
font.setPointSize(font_size)
fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(text)
if text_width <= available_width:
chosen_size = font_size
break
font.setPointSize(chosen_size)
self.setFont(font)
# После выбора шрифта вычисляем требуемую ширину для полного отображения текста
fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(text)
required_width = text_width + margins.left() + margins.right() + self._padding * 2
if self._icon:
required_width += self._icon_size
# Если текущая ширина меньше требуемой, обновляем минимальную ширину
if self.width() < required_width:
self.setMinimumWidth(required_width)
super().setText(text)
def sizeHint(self):
if not self._update_size:
return super().sizeHint()
else:
# Вычисляем оптимальный размер кнопки на основе текста и отступов
font = self.font()
fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(self._original_text)
margins = self.contentsMargins()
width = text_width + margins.left() + margins.right() + self._padding * 2
if self._icon:
width += self._icon_size
height = fm.height() + margins.top() + margins.bottom() + self._padding
return QSize(width, height)
class NavLabel(QLabel):
clicked = Signal()
def __init__(self, text="", parent=None):
super().__init__(text, parent)
self.setWordWrap(True)
self.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter)
self._checkable = False
self._isChecked = False
self.setProperty("checked", self._isChecked)
self.setCursor(Qt.CursorShape.PointingHandCursor)
# Explicitly enable focus
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def setCheckable(self, checkable):
self._checkable = checkable
def setChecked(self, checked):
if self._checkable:
self._isChecked = checked
self.setProperty("checked", checked)
self.style().unpolish(self)
self.style().polish(self)
self.update()
def isChecked(self):
return self._isChecked
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
# Ensure widget can take focus on click
self.setFocus(Qt.FocusReason.MouseFocusReason)
if self._checkable:
self.setChecked(not self._isChecked)
self.clicked.emit()
event.accept()
else:
super().mousePressEvent(event)