Files
PortProtonQt/portprotonqt/custom_widgets.py
Boris Yumankulov ff744fc581
All checks were successful
Code check / Check code (push) Successful in 1m10s
chore(flow_layout): drop very heavy numpy
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2026-01-05 00:02:17 +05:00

510 lines
17 KiB
Python

from PySide6.QtWidgets import QLabel, QPushButton, QWidget, QLayout, QLayoutItem
from PySide6.QtCore import Qt, Signal, QRect, QSize
from PySide6.QtGui import QFont, QFontMetrics, QPainter
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
"""
Оптимизированная версия на чистом Python без numpy.
nat_sizes: list of tuples [(width, height), ...]
"""
N = len(nat_sizes)
if N == 0:
return [], 0
result = [[0, 0, 0, 0] for _ in range(N)]
min_margin = 20
available_width = rect_width - 2 * min_margin
# Быстрый поиск максимального количества элементов в строке
max_items_per_row = 1
global_scale = 1.0
max_row_x_start = min_margin
i = 0
while i < N:
# Бинарный поиск максимального количества элементов
left, right = 1, N - i
best_count = 1
while left <= right:
mid = (left + right) // 2
end_idx = min(i + mid, N)
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
needed_width = sum_w + spacing * (mid - 1)
if needed_width <= available_width:
best_count = mid
left = mid + 1
else:
right = mid - 1
count = best_count
sum_width = sum(nat_sizes[j][0] for j in range(i, i + count))
if count > max_items_per_row:
max_items_per_row = count
desired_scale = available_width / (sum_width + spacing * (count - 1)) if sum_width > 0 else 1.0
global_scale = min(desired_scale, max_scale)
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
i += count
# Второй проход: размещение элементов
y = 0
i = 0
while i < N:
# Бинарный поиск для текущей строки
left, right = 1, N - i
best_count = 1
while left <= right:
mid = (left + right) // 2
end_idx = min(i + mid, N)
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
needed_width = sum_w + spacing * (mid - 1)
if needed_width <= available_width:
best_count = mid
left = mid + 1
else:
right = mid - 1
count = best_count
j = i + count
# Расчёт размеров для строки
sum_width = 0
row_max_height = 0
for k in range(i, j):
w, h = nat_sizes[k]
sum_width += w
if h > row_max_height:
row_max_height = h
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
# Определение начальной позиции
if count == max_items_per_row:
x = max(min_margin, (rect_width - scaled_row_width) // 2)
else:
x = max_row_x_start
# Размещение элементов в строке
for k in range(i, j):
w, h = nat_sizes[k]
new_w = int(w * global_scale)
new_h = int(h * global_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 * global_scale) + spacing
i = j
return result, y
class FlowLayout(QLayout):
def __init__(self, parent=None):
super().__init__(parent)
self.itemList = []
self.setContentsMargins(20, 20, 20, 20)
self._spacing = 20
self._max_scale = 1.0
# Простой кеш
self._cache_width = None
self._cache_visible_hash = None
self._cache_result = None
def _get_visible_data(self):
"""Возвращает список видимых элементов и их размеры"""
visible_items = []
visible_indices = []
visible_sizes = []
for i, item in enumerate(self.itemList):
widget = item.widget()
if widget and widget.isVisible():
visible_items.append(item)
visible_indices.append(i)
s = item.sizeHint()
visible_sizes.append((s.width(), s.height()))
return visible_items, visible_indices, visible_sizes
def _make_visible_hash(self, visible_sizes):
"""Создаёт хеш для проверки изменений"""
return hash(tuple(visible_sizes))
def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item)
self._invalidate_cache()
def takeAt(self, index: int) -> QLayoutItem:
if 0 <= index < len(self.itemList):
self._invalidate_cache()
return self.itemList.pop(index)
raise IndexError("Index out of range")
def _invalidate_cache(self):
self._cache_width = None
self._cache_visible_hash = None
self._cache_result = None
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):
_, _, visible_sizes = self._get_visible_data()
if not visible_sizes:
return 0
# Проверка кеша
visible_hash = self._make_visible_hash(visible_sizes)
if (self._cache_width == width and
self._cache_visible_hash == visible_hash and
self._cache_result is not None):
return self._cache_result[1]
# Вычисление
geom_array, total_height = compute_layout(visible_sizes, width,
self._spacing, self._max_scale)
# Сохранение в кеш
self._cache_width = width
self._cache_visible_hash = visible_hash
self._cache_result = (geom_array, total_height)
return total_height
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_total = len(self.itemList)
if N_total == 0:
return 0
visible_items, visible_indices, visible_sizes = self._get_visible_data()
if not visible_sizes:
if not testOnly:
for item in self.itemList:
item.setGeometry(QRect())
return 0
# Проверка кеша
visible_hash = self._make_visible_hash(visible_sizes)
if (self._cache_width == rect.width() and
self._cache_visible_hash == visible_hash and
self._cache_result is not None):
geom_array, total_height = self._cache_result
else:
# Вычисление layout
geom_array, total_height = compute_layout(visible_sizes, rect.width(),
self._spacing, self._max_scale)
# Сохранение в кеш
self._cache_width = rect.width()
self._cache_visible_hash = visible_hash
self._cache_result = (geom_array, total_height)
if not testOnly:
rx, ry = rect.x(), rect.y()
# Установка геометрии для видимых элементов
for idx, item in enumerate(visible_items):
x, y, w, h = geom_array[idx]
item.setGeometry(QRect(x + rx, y + ry, w, h))
# Скрытие невидимых элементов
visible_set = set(visible_indices)
for i in range(N_total):
if i not in visible_set:
self.itemList[i].setGeometry(QRect())
return total_height
class ClickableLabel(QLabel):
clicked = Signal()
def __init__(self, *args, icon=None, icon_size=16, icon_space=5, change_cursor=True, font_scale_factor=0.06, **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__(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._font_scale_factor = font_scale_factor
self._card_width = 250
if change_cursor:
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.updateFontSize()
def setIcon(self, icon):
self._icon = icon
self.update()
def icon(self):
return self._icon
def setIconSize(self, icon_size: int, icon_space: int):
self._icon_size = icon_size
self._icon_space = icon_space
self.update()
def setCardWidth(self, card_width: int):
self._card_width = card_width
self.updateFontSize()
def updateFontSize(self):
font = self.font()
font_size = int(self._card_width * self._font_scale_factor)
font.setPointSize(max(8, font_size))
self.setFont(font)
self.update()
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
text = self.text()
if self._icon:
pixmap = self._icon.pixmap(icon_size, icon_size)
else:
pixmap = None
fm = QFontMetrics(self.font())
available_width = rect.width()
if pixmap:
available_width -= (icon_size + spacing)
available_width = max(0, available_width - 4)
display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width)
text_width = fm.horizontalAdvance(display_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 = QRect(x, y + (text_height - icon_size) // 2, icon_size, icon_size)
painter.drawPixmap(icon_rect, pixmap)
text_x = x + icon_size + spacing
else:
text_x = x
text_rect = QRect(text_x, y, text_width, text_height)
self.style().drawItemText(
painter,
text_rect,
alignment,
self.palette(),
self.isEnabled(),
display_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)
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:
self.setFocus(Qt.FocusReason.MouseFocusReason)
if self._checkable:
self.setChecked(not self._isChecked)
self.clicked.emit()
event.accept()
else:
super().mousePressEvent(event)