# -*- 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().