Files
2026-04-29 08:18:54 +04:00

336 lines
14 KiB
Python
Raw Permalink 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.
# -*- coding: utf-8 -*-
# gui/components/tab_widget.py
from typing import Any, List, Optional, Type
from PySide6.QtCore import Signal
from PySide6.QtGui import QResizeEvent, QShowEvent
from PySide6.QtWidgets import QWidget
from gui.containers.s_container import SContainer
from gui.containers.h_container import HContainer
from gui.containers.stack_container import StackContainer
from gui.components.tab_button import TabButton
from error_logger import log_exception
class TabWidget(SContainer):
"""
Процентный TabWidget:
- Верхняя панель вкладок (HContainer) заданной высоты в %
- Контент (StackContainer + QStackedWidget) занимает оставшуюся высоту
"""
currentChanged = Signal(int)
def __init__(
self,
width_percent: Optional[int] = None,
height_percent: Optional[int] = None,
tab_bar_height_percent: int = 8,
margin: int = 0,
parent: Optional[QWidget] = None,
content_fit: bool = True,
style: Optional[str] = None,
active_style: Optional[str] = None,
is_active: Optional[bool] = None,
button_cls: Type = TabButton,
button_style_normal: Optional[str] = None,
button_style_active: Optional[str] = None,
button_width_percent: Optional[int] = None,
button_height_percent: Optional[int] = None,
button_margin: Optional[list[int]] = None,
button_inner_margin: Optional[int] = None,
tab_bar_content_fit: bool = True,
):
super().__init__(
width_percent=width_percent,
height_percent=height_percent,
orientation="v",
margin=margin,
content_fit=content_fit,
parent=parent
)
if not (1 <= tab_bar_height_percent <= 99):
raise ValueError("tab_bar_height_percent должен быть в диапазоне 1..99")
self._tab_bar_height_percent = tab_bar_height_percent
self._content_height_percent = 100 - tab_bar_height_percent
# Верхняя панель вкладок
self._tab_bar = HContainer(
height_percent=self._tab_bar_height_percent,
margin=0,
content_fit=tab_bar_content_fit,
)
# Контентная часть
self._content_container = StackContainer(
width_percent=100,
height_percent=self._content_height_percent,
margin=0
)
# Добавляем в основной VContainer
self.add_widget(self._tab_bar)
self.add_widget(self._content_container)
self._button_cls = button_cls
self._button_style_normal = button_style_normal
self._button_style_active = button_style_active
self._button_width_percent = button_width_percent
self._button_height_percent = button_height_percent
self._button_margin = button_margin
self._button_inner_margin = button_inner_margin
self._buttons: List[Any] = []
if style is not None or active_style is not None or is_active is not None:
self.style(style_key=style, active_key=active_style, is_active=is_active)
# -------------------- Публичный API --------------------
def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self._recompute_tab_button_widths()
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self._recompute_tab_button_widths()
def style(
self,
style_key: Optional[str] = None,
active_key: Optional[str] = None,
is_active: Optional[bool] = None,
) -> None:
"""Короткий метод применения стиля для TabWidget."""
super().style(style_key=style_key, active_key=active_key, is_active=is_active)
def add_tab(self, widget: QWidget, title: str) -> int:
"""
Добавляет вкладку и страницу.
Возвращает индекс новой вкладки.
"""
index = len(self._buttons)
btn = self._button_cls(
text=title,
index=index,
width_percent=self._button_width_percent,
height_percent=self._button_height_percent or 100,
margin=self._button_margin if self._button_margin is not None else 0,
text_left_margin=self._button_inner_margin,
style=self._button_style_normal,
active_style=self._button_style_active,
)
btn.clicked.connect(lambda checked=False, i=index: self.set_current_index(i))
self._tab_bar.add_widget(btn)
self._content_container.add_widget(widget)
self._buttons.append(btn)
self._recompute_tab_button_widths()
# Если это первая вкладка — активируем
if index == 0:
self.set_current_index(0)
return index
def set_current_index(self, index: int) -> None:
if index < 0 or index >= self.count():
return
self._content_container.set_current_index(index)
self._apply_active_style(index)
self.currentChanged.emit(index)
def current_index(self) -> int:
return self._content_container.current_index()
def widget(self, index: int) -> Optional[QWidget]:
return self._content_container.widget(index)
def count(self) -> int:
return self._content_container.count()
def remove_tab(self, index: int) -> None:
if index < 0 or index >= self.count():
return
# 1) убрать кнопку
btn = self._buttons.pop(index)
btn.setParent(None)
btn.deleteLater()
# 2) убрать страницу
w = self._content_container.widget(index)
if w is not None:
self._content_container.remove_widget(w)
if w is not None:
w.setParent(None)
# 3) переиндексация кнопок и их callback
for i, b in enumerate(self._buttons):
b.index = i
if hasattr(b, "set_property"):
b.set_property("tab_index", i)
try:
b.clicked.disconnect()
except Exception as e:
log_exception(__name__, "remove_tab.disconnect", e)
b.clicked.connect(lambda checked=False, ii=i: self.set_current_index(ii))
self._recompute_tab_button_widths()
# 4) корректировка текущей
if self.count() > 0:
self.set_current_index(min(index, self.count() - 1))
def set_tab_text(self, index: int, text: str) -> None:
if 0 <= index < len(self._buttons):
self._buttons[index].set_text(text)
def set_tab_enabled(self, index: int, enabled: bool) -> None:
if 0 <= index < len(self._buttons):
self._buttons[index].set_enabled(enabled)
def add_tab_bar_spacer(self, stretch: int = 1) -> None:
"""Добавляет растягиваемый спейсер в панель вкладок (прижимает кнопки влево)."""
self._tab_bar.add_stretch(stretch)
# -------------------- Внутренние --------------------
def _apply_active_style(self, active_index: int) -> None:
for i, b in enumerate(self._buttons):
if i == active_index:
b.style(is_active=True)
else:
b.style(is_active=False)
def _recompute_tab_button_widths(self) -> None:
"""
Делит 100% ширины между кнопками вкладок.
Важное: проценты целые, сумма строго 100.
"""
n = len(self._buttons)
if n == 0:
return
base = 100 // n
rem = 100 % n
for i, b in enumerate(self._buttons):
if self._button_width_percent is not None:
w = self._button_width_percent
else:
w = base + (1 if i < rem else 0)
h = self._button_height_percent or 100
# API PercentSizedWidget
b.set_percent_sizes(width_percent=w, height_percent=h)
# Чтобы компоновка пересчиталась сразу
self._tab_bar.invalidate_layout()
tab_bar_layout = self._tab_bar.get_layout()
if tab_bar_layout is not None:
tab_bar_layout.activate()
self._tab_bar.updateGeometry()
self.updateGeometry()
# ---------------------------------------------------------------------------
# Module workflow notes
# ---------------------------------------------------------------------------
#
# 1) Назначение модуля:
# Процентный TabWidget с верхней панелью вкладок (HContainer)
# заданной высоты в % и контентной частью (StackContainer),
# занимающей оставшуюся высоту.
#
# 2) Зависимости модуля:
# Импорты: Any, List, Optional, Type (typing),
# Signal, Qt (PySide6.QtCore),
# QResizeEvent, QShowEvent (PySide6.QtGui),
# QWidget (PySide6.QtWidgets),
# SContainer (gui.containers.s_container),
# HContainer (gui.containers.h_container),
# StackContainer (gui.containers.stack_container),
# TabButton (gui.components.tab_button)
# Хост-класс / базовый класс: SContainer (orientation="v")
# Внешние библиотеки: PySide6 (обязательна)
#
# 3) Экспорт:
# Класс TabWidget — контейнер с вкладками.
# Сигнал: currentChanged(int).
# Методы: add_tab(widget, title) -> index, set_current_index(int),
# current_index(), widget(index), count(), remove_tab(index),
# set_tab_text(), set_tab_enabled(), add_tab_bar_spacer(),
# style().
#
# 4) Состояние (поля):
# _tab_bar_height_percent: int — высота панели вкладок в %
# _content_height_percent: int — высота контента (100 - tab_bar)
# _tab_bar: HContainer — горизонтальный контейнер кнопок
# _content_container: StackContainer — стек страниц
# _button_cls: Type — класс кнопки (по умолчанию TabButton)
# _button_style_normal: str|None — нормальный стиль кнопок
# _button_style_active: str|None — активный стиль кнопок
# _button_width/height_percent: int|None — размеры кнопок
# _button_margin: list[int]|None — отступы кнопок
# _button_inner_margin: int|None — внутренний отступ текста
# _buttons: list[TabButton] — список кнопок вкладок
#
# 5) Последовательность действий и вызовов:
# __init__(tab_bar_height_percent, ...)
# -> super().__init__(orientation="v")
# -> HContainer(height_percent) — панель вкладок
# -> StackContainer(height_percent) — стек контента
# -> self.add_widget(_tab_bar) + self.add_widget(_content_container)
# add_tab(widget, title)
# -> TabButton(text, index, ...) -> clicked.connect(set_current_index)
# -> _tab_bar.add_widget(btn) -> _content_container.add_widget(widget)
# -> _recompute_tab_button_widths()
# -> если первая вкладка → set_current_index(0)
# set_current_index(index)
# -> _content_container.set_current_index(index)
# -> _apply_active_style(index) — is_active=True для текущей, False для остальных
# -> currentChanged.emit(index)
# remove_tab(index)
# -> удалить кнопку, удалить страницу, переиндексировать оставшиеся
# -> _recompute_tab_button_widths()
# _recompute_tab_button_widths()
# -> 100% / n кнопок → set_percent_sizes на каждую кнопку
# -> invalidate_layout + activate + updateGeometry
#
# 6) Побочные эффекты:
# - Модифицирует _tab_bar и _content_container при add/remove.
# - Испускает currentChanged при смене вкладки.
# - Пересчитывает ширину кнопок при showEvent/resizeEvent.
#
# 7) Границы ответственности:
# Модуль управляет переключением страниц и визуальным состоянием
# вкладок. НЕ определяет стили кнопок — делегирует TabButton.
# НЕ создаёт содержимое страниц.
#
# 8) Обработка ошибок:
# tab_bar_height_percent вне [1, 99] → ValueError.
# set_current_index() / remove_tab() — проверка диапазона индекса.
# disconnect() в remove_tab() обёрнут в try/except.
#
# 9) Инварианты и контракты:
# - tab_bar_height_percent ∈ [1, 99].
# - len(_buttons) == _content_container.count().
# - Сумма width_percent всех кнопок == 100 (при автораскладке).
# - Индексы кнопок совпадают с индексами страниц в StackContainer.
#
# 10) Правило сопровождения:
# Для кастомизации кнопок — передать button_cls в конструктор.
# Не манипулировать _tab_bar и _content_container напрямую —
# использовать add_tab() / remove_tab().