336 lines
14 KiB
Python
336 lines
14 KiB
Python
# -*- 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().
|