plate-config-app/app/main_window.py
Danil Iarosh 9fd10a3acd v1.3.0: simple mode backlight and UI polish
- обновлён простой режим: тест цвета и проверка подсветки кнопок объединены в раздел «Тест подсветки»

- добавлена проверка подсветки кнопок через 8-й силовой выход с автоматическим коротким тестом

- упрощены описания пользовательских проверок и убраны лишние технические подробности из простого режима

- изменена раскладка теста цвета, увеличена длительность шага до 1 секунды и убраны описания из PRO-блока

- добавлена защита выпадающих списков от случайного изменения колесом мыши

- улучшены немодальные PRO-диалоги, настройки соединения и обработка событий COM-порта

- обновлены визуальные детали приложения, пользовательские ресурсы, инструкции AGENTS и Release Notes 1.3.0
2026-05-26 12:59:21 +03:00

337 lines
13 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.

from __future__ import annotations
from PySide6.QtCore import QSize, Qt, QRectF
from PySide6.QtGui import (
QCloseEvent,
QColor,
QPainter,
QPainterPath,
QPen,
QRegion,
QResizeEvent,
QShowEvent,
)
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QMainWindow,
QStackedWidget,
QTabBar,
QToolButton,
QVBoxLayout,
QWidget,
)
from .ui.icons import ICON_COLOR_ACCENT, ICON_COLOR_MUTED, ICON_COLOR_PRIMARY, icon
from .ui.theme import build_stylesheet, get_palette
from .services import settings_service
from .widgets.connection_tab import ConnectionTab
from .widgets.start_tab import StartTab
from .widgets.title_bar import TitleBar
from . import __version__
class _WindowBorderOverlay(QWidget):
"""Paints the accent border above all window content."""
def __init__(self, parent: QWidget, radius: int) -> None:
super().__init__(parent)
self._radius = radius
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False)
self.setObjectName("WindowBorderOverlay")
def paintEvent(self, event) -> None: # type: ignore[override]
parent = self.parentWidget()
if parent is None:
return
if parent.isMaximized() or parent.isFullScreen():
return
accent = get_palette(
settings_service.theme_mode(),
settings_service.theme_accent(),
).accent
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
p.setBrush(Qt.BrushStyle.NoBrush)
p.setPen(QPen(QColor(accent), 2))
rect = self.rect().adjusted(1, 1, -1, -1)
p.drawRoundedRect(QRectF(rect), self._radius, self._radius)
p.end()
class MainWindow(QMainWindow):
_WINDOW_RADIUS = 10
def __init__(self) -> None:
super().__init__()
self.setObjectName("AppWindow")
self.setWindowTitle(f"Plate Config App v{__version__}")
self.resize(820, 560)
self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setMinimumSize(760, 500)
container = QWidget()
container.setObjectName("MainContainer")
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(0)
self.title_bar = TitleBar(self)
container_layout.addWidget(self.title_bar)
self.tabs = QTabBar()
self.tabs.setObjectName("TitleTabs")
self.tabs.setDrawBase(False)
self.tabs.setExpanding(False)
self.tabs.setElideMode(Qt.TextElideMode.ElideRight)
self.tabs.setIconSize(QSize(12, 12))
self.tabs.currentChanged.connect(self._on_tab_changed)
self.title_bar.set_tabs_widget(self.tabs)
self.content_frame = QFrame()
self.content_frame.setObjectName("ContentArea")
content_layout = QVBoxLayout(self.content_frame)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(0)
self.pages = QStackedWidget()
content_layout.addWidget(self.pages)
container_layout.addWidget(self.content_frame, 1)
self.setCentralWidget(container)
self._border_overlay = _WindowBorderOverlay(self, self._WINDOW_RADIUS)
self._border_overlay.setGeometry(self.rect())
self._border_overlay.raise_()
start_tab = StartTab(self.new_connection_tab)
self._add_tab(start_tab, "Главная", icon_name="fa5s.home", closable=False)
self.tabs.setCurrentIndex(0)
def _update_window_mask(self) -> None:
"""Apply rounded mask so bottom corners are physically clipped."""
if self.isMaximized() or self.isFullScreen():
self.clearMask()
return
if self.width() <= 0 or self.height() <= 0:
return
path = QPainterPath()
path.addRoundedRect(QRectF(self.rect()), self._WINDOW_RADIUS, self._WINDOW_RADIUS)
self.setMask(QRegion(path.toFillPolygon().toPolygon()))
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self._update_window_mask()
self._border_overlay.setGeometry(self.rect())
self._border_overlay.raise_()
self._border_overlay.update()
def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self._update_window_mask()
self._border_overlay.setGeometry(self.rect())
self._border_overlay.raise_()
self._border_overlay.update()
def new_connection_tab(self) -> None:
tab: ConnectionTab | None = None
def get_index() -> int:
if tab is None:
return -1
return self.pages.indexOf(tab)
tab = ConnectionTab(
tab_index_getter=get_index,
set_tab_title=self._set_tab_title,
close_tab=self._close_tab,
set_tab_indicator=self._set_tab_indicator,
)
index = self._add_tab(tab, "Новое соединение", icon_name="fa5s.plug", closable=True)
self.tabs.setCurrentIndex(index)
# ── Управление вкладками ────────────────────────────────────────
def _close_tab(self, index: int) -> None:
if index == 0:
return
widget = self.pages.widget(index)
if widget is None:
return
if isinstance(widget, ConnectionTab):
widget.close_serial()
self.pages.removeWidget(widget)
widget.deleteLater()
self.tabs.removeTab(index)
# обновляем текущую вкладку и иконки (индексы сдвинулись)
if self.tabs.count() > 0:
self.tabs.setCurrentIndex(min(index, self.tabs.count() - 1))
self._refresh_tab_icons()
def closeEvent(self, event: QCloseEvent) -> None:
for i in range(self.pages.count()):
widget = self.pages.widget(i)
if isinstance(widget, ConnectionTab):
widget.close_serial()
super().closeEvent(event)
def _attach_close_button(self, index: int) -> None:
# Контейнер: [индикатор] [кнопка закрытия]
container = QWidget(self.tabs)
container.setObjectName("TabRightContainer")
h = QHBoxLayout(container)
h.setContentsMargins(2, 0, 4, 0)
h.setSpacing(4)
# Индикатор состояния (скрыт по умолчанию)
indicator = QWidget(container)
indicator.setObjectName("TabStatusDot")
indicator.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
indicator.setFixedSize(8, 8)
indicator.setProperty("dotColor", "none")
indicator.hide()
h.addWidget(indicator, 0, Qt.AlignmentFlag.AlignVCenter)
close_button = QToolButton(container)
close_button.setText("×")
close_icon = icon("fa5s.times", color=ICON_COLOR_MUTED)
if not close_icon.isNull():
close_button.setIcon(close_icon)
close_button.setText("")
close_button.setCursor(Qt.CursorShape.PointingHandCursor)
close_button.setObjectName("TabCloseButton")
close_button.setProperty("tabClose", True)
close_button.setAutoRaise(True)
close_button.setFixedSize(14, 14)
close_button.setIconSize(QSize(10, 10))
close_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
close_button.clicked.connect(self._close_tab_from_button)
h.addWidget(close_button, 0, Qt.AlignmentFlag.AlignVCenter)
# Фиксируем размер с учётом индикатора (даже скрытого),
# чтобы QTabBar не пересчитывал позицию при показе/скрытии
indicator.show()
full_size = container.sizeHint()
indicator.hide()
container.setFixedSize(full_size)
self.tabs.setTabButton(index, QTabBar.ButtonPosition.RightSide, container)
def _set_tab_indicator(self, index: int, color: str) -> None:
"""Установить цвет индикатора (green / yellow / red / none) для вкладки."""
container = self.tabs.tabButton(index, QTabBar.ButtonPosition.RightSide)
if container is None:
return
indicator = container.findChild(QWidget, "TabStatusDot")
if indicator is None:
return
if color == "none":
indicator.hide()
else:
indicator.setProperty("dotColor", color)
indicator.show()
# Перезагрузить стиль
style = indicator.style()
style.unpolish(indicator)
style.polish(indicator)
indicator.update()
def _refresh_tab_icons(self) -> None:
"""Обновляет иконки вкладок и иконки кнопок закрытия в зависимости от текущей вкладки."""
current = self.tabs.currentIndex()
for index in range(self.tabs.count()):
clr = ICON_COLOR_ACCENT if index == current else ICON_COLOR_PRIMARY
page = self.pages.widget(index)
icon_name = getattr(page, "_tab_icon_name", None)
if icon_name:
tab_icon = icon(icon_name, color=clr)
if not tab_icon.isNull():
self.tabs.setTabIcon(index, tab_icon)
# Кнопка закрытия теперь внутри контейнера
container = self.tabs.tabButton(index, QTabBar.ButtonPosition.RightSide)
if container is not None:
close_btn = container.findChild(QToolButton, "TabCloseButton")
if close_btn is not None:
close_icon = icon("fa5s.times", color=clr)
if not close_icon.isNull():
close_btn.setIcon(close_icon)
def _close_tab_from_button(self) -> None:
sender = self.sender()
for index in range(self.tabs.count()):
container = self.tabs.tabButton(index, QTabBar.ButtonPosition.RightSide)
if container is not None:
close_btn = container.findChild(QToolButton, "TabCloseButton")
if close_btn is sender:
self._close_tab(index)
return
def _add_tab(self, widget: QWidget, title: str, icon_name: str, *, closable: bool) -> int:
index = self.pages.addWidget(widget)
self.tabs.addTab(title)
# сохраняем имя иконки в виджете для дальнейшего обновления цвета
setattr(widget, "_tab_icon_name", icon_name)
tab_icon = icon(icon_name, color=ICON_COLOR_MUTED)
if not tab_icon.isNull():
self.tabs.setTabIcon(index, tab_icon)
if closable:
self._attach_close_button(index)
else:
self.tabs.setTabButton(index, QTabBar.ButtonPosition.LeftSide, None)
self.tabs.setTabButton(index, QTabBar.ButtonPosition.RightSide, None)
# синхронизируем цвета иконок
self._refresh_tab_icons()
return index
def _set_tab_title(self, index: int, title: str) -> None:
if 0 <= index < self.tabs.count():
self.tabs.setTabText(index, title)
# поставить иконку "подключено" и сохранить её имя для управления цветом
page = self.pages.widget(index)
if page is not None:
setattr(page, "_tab_icon_name", "fa5s.microchip")
connected_icon = icon("fa5s.microchip", color=ICON_COLOR_PRIMARY)
if not connected_icon.isNull():
self.tabs.setTabIcon(index, connected_icon)
self._refresh_tab_icons()
def _on_tab_changed(self, index: int) -> None:
if index >= 0:
self.pages.setCurrentIndex(index)
self._refresh_tab_icons()
# ── Theme ────────────────────────────────────────────────────
def apply_theme(self, mode: str, accent: str) -> None:
"""Apply a new theme palette and refresh all icons."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if isinstance(app, QApplication):
palette = get_palette(mode, accent)
app.setStyleSheet(build_stylesheet(palette))
# Refresh icons on every page
for i in range(self.pages.count()):
page = self.pages.widget(i)
refresh = getattr(page, "refresh_icons", None)
if callable(refresh):
refresh()
self._refresh_tab_icons()
self._border_overlay.update()
def apply_runtime_settings(self) -> None:
"""Propagate runtime settings changes to all connection tabs."""
for i in range(self.pages.count()):
page = self.pages.widget(i)
if isinstance(page, ConnectionTab):
page.apply_runtime_settings()