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)