Switch to standard OS windows, resizable dialogs, and channel-aware pixel test

- Удален кастомный безрамочный режим (FramelessWindowHint) главного окна и диалогов.
- Внедрено нативное управление окнами операционной системы (изменение размеров, максимизация, минимизация).
- Добавлено применение QPalette под выбранную тему (темную/свелую) и акцент для покраски системных элементов окон.
- Внутреннее содержимое диалогов (StyledDialog) обернуто в QScrollArea с автоскроллом и ограничением по высоте до 720px во избежание выхода окон за пределы экрана.
- TitleBar упрощен до простой верхней панели вкладок (sub-header/tabs).
- В диалог теста подсветки добавлен выбор номера ленты (1..4) при тесте пикселя виртуальной матрицы, обновлена логика генерации и отправки тестовой команды.
- Раздел "Бродяга морей" переименован в "Бродяги морей".
This commit is contained in:
Данил Ярош 2026-07-01 14:58:11 +03:00
parent dfd4f0e0ba
commit 4ce9eb7689
6 changed files with 86 additions and 346 deletions

View File

@ -8,7 +8,7 @@ from PySide6.QtWidgets import QApplication
from .main_window import MainWindow
from .services import settings_service
from .ui.icons import ICON_COLOR_PRIMARY, icon
from .ui.theme import build_stylesheet, get_palette
from .ui.theme import build_stylesheet, get_palette, create_qpalette
def main() -> None:
@ -18,6 +18,7 @@ def main() -> None:
palette = get_palette(settings_service.theme_mode(),
settings_service.theme_accent())
app.setPalette(create_qpalette(palette))
app.setStyleSheet(build_stylesheet(palette))
app_icon = icon("fa5s.microchip", color=ICON_COLOR_PRIMARY)
if not app_icon.isNull():

View File

@ -23,7 +23,7 @@ from PySide6.QtWidgets import (
)
from .ui.icons import ICON_COLOR_ACCENT, ICON_COLOR_MUTED, ICON_COLOR_PRIMARY, icon
from .ui.theme import build_stylesheet, get_palette
from .ui.theme import build_stylesheet, get_palette, create_qpalette
from .services import settings_service
from .widgets.connection_tab import ConnectionTab
from .widgets.start_tab import StartTab
@ -32,47 +32,14 @@ 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.setWindowFlags(Qt.WindowType.Window)
self.setMinimumSize(760, 500)
container = QWidget()
@ -104,38 +71,12 @@ class MainWindow(QMainWindow):
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
@ -316,6 +257,7 @@ class MainWindow(QMainWindow):
app = QApplication.instance()
if isinstance(app, QApplication):
palette = get_palette(mode, accent)
app.setPalette(create_qpalette(palette))
app.setStyleSheet(build_stylesheet(palette))
# Refresh icons on every page
@ -326,7 +268,6 @@ class MainWindow(QMainWindow):
refresh()
self._refresh_tab_icons()
self._border_overlay.update()
def apply_runtime_settings(self) -> None:
"""Propagate runtime settings changes to all connection tabs."""

View File

@ -11,6 +11,8 @@ from dataclasses import dataclass, asdict
import os
import tempfile
from PySide6.QtGui import QColor, QPalette
@dataclass(frozen=True)
class ThemePalette:
@ -219,32 +221,25 @@ def get_palette(mode: str = "light", accent: str = "green") -> ThemePalette:
_QSS_TEMPLATE = """
QMainWindow#AppWindow {{
background: transparent;
background: {bg_base};
}}
QWidget#MainContainer {{
background: {bg_base};
border: none;
border-radius: 10px;
}}
QFrame#ContentArea {{
background: {bg_base};
border: none;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}}
QStackedWidget {{
background: {bg_base};
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}}
QScrollArea {{
background: {bg_base};
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}}
QScrollArea > QWidget > QWidget {{
@ -253,11 +248,10 @@ QScrollArea > QWidget > QWidget {{
QWidget#TitleBar {{
background: {accent};
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom: 1px solid {accent};
}}
QLabel#TitleBarTitle {{
color: #f8fafc;
font-size: 13px;
@ -571,25 +565,6 @@ QMessageBox, QDialog {{
color: {text_primary};
}}
QWidget#DialogContainer {{
background: {bg_dialog};
border: 2px solid {accent};
border-radius: 10px;
}}
QWidget#DialogTitleBar {{
background: {accent};
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom: 1px solid {accent};
}}
QLabel#DialogTitle {{
color: #f8fafc;
font-size: 13px;
font-weight: 600;
}}
QMessageBox QLabel {{
color: {text_primary};
font-size: 13px;
@ -859,6 +834,24 @@ def _arrow_svg_path(name: str, color: str, points: str) -> str:
return fpath.replace("\\", "/")
def create_qpalette(palette: ThemePalette) -> QPalette:
qpal = QPalette()
qpal.setColor(QPalette.Window, QColor(palette.bg_base))
qpal.setColor(QPalette.WindowText, QColor(palette.text_primary))
qpal.setColor(QPalette.Base, QColor(palette.bg_card))
qpal.setColor(QPalette.AlternateBase, QColor(palette.bg_input))
qpal.setColor(QPalette.ToolTipBase, QColor(palette.bg_tooltip))
qpal.setColor(QPalette.ToolTipText, QColor(
palette.text_on_accent if "#1e" in palette.bg_tooltip or "#0" in palette.bg_tooltip else palette.text_primary
))
qpal.setColor(QPalette.Text, QColor(palette.text_primary))
qpal.setColor(QPalette.Button, QColor(palette.btn_bg))
qpal.setColor(QPalette.ButtonText, QColor(palette.text_primary))
qpal.setColor(QPalette.Highlight, QColor(palette.accent))
qpal.setColor(QPalette.HighlightedText, QColor(palette.text_on_accent))
return qpal
def build_stylesheet(palette: ThemePalette) -> str:
d = asdict(palette)
# Генерируем SVG-стрелки

View File

@ -220,8 +220,8 @@ class LedTestDialog(StyledDialog):
root.addWidget(color_group)
# ── Бродяга морей (cmd 81) ──
wanderer_group = QGroupBox("Бродяга морей (cmd 81)")
# ── Бродяги морей (cmd 81) ──
wanderer_group = QGroupBox("Бродяги морей (cmd 81)")
wanderer_form = QFormLayout(wanderer_group)
wanderer_form.setSpacing(6)
@ -328,6 +328,16 @@ class LedTestDialog(StyledDialog):
self._m_test_type.currentIndexChanged.connect(self._on_matrix_test_type_changed)
test_type_layout.addWidget(self._m_test_type, 1)
self._m_strip_label = QLabel("Лента:")
self._m_strip_label.setVisible(False)
test_type_layout.addWidget(self._m_strip_label)
self._m_strip_spin = QSpinBox()
self._m_strip_spin.setRange(1, 4)
self._m_strip_spin.setValue(1)
self._m_strip_spin.setVisible(False)
test_type_layout.addWidget(self._m_strip_spin)
self._m_test_param = QSpinBox()
self._m_test_param.setRange(0, 999)
self._m_test_param.setValue(0)
@ -574,6 +584,9 @@ class LedTestDialog(StyledDialog):
def _on_matrix_test_type_changed(self, index: int) -> None:
test_type = self._m_test_type.itemData(index)
is_pixel_test = (test_type == 4)
self._m_strip_label.setVisible(is_pixel_test)
self._m_strip_spin.setVisible(is_pixel_test)
self._m_test_param.setVisible(test_type >= 2)
def _send_matrix_test(self) -> None:
@ -587,8 +600,9 @@ class LedTestDialog(StyledDialog):
val = self._m_test_param.value()
cmd = f"{mode.base_command}25{test_type}{val:02d}"
elif test_type == 4:
val = self._m_test_param.value()
cmd = f"{mode.base_command}25{test_type}{val:03d}"
strip = self._m_strip_spin.value()
pixel = self._m_test_param.value()
cmd = f"{mode.base_command}254{strip}{pixel:02d}"
self._send(cmd)
def _send_matrix_scale(self, index: int) -> None:

View File

@ -1,133 +1,68 @@
"""Базовый класс для стилизованных диалоговых окон.
Убирает системную рамку, делает фон прозрачным,
добавляет контейнер с зелёной обводкой и скруглёнными углами,
мини-заголовок с кнопкой закрытия и перетаскиванием окна.
Использует стандартные системные рамки и заголовки, поддерживает
изменение размеров, сворачивание и разворачивание диалогов.
Внутреннее содержимое оборачивается в прокручиваемую область (QScrollArea),
чтобы избежать проблем с выходом окон за границы экрана при низком разрешении.
"""
from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QDialog,
QGraphicsDropShadowEffect,
QHBoxLayout,
QLabel,
QToolButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from ...ui.icons import ICON_COLOR_DANGER, icon
class StyledDialog(QDialog):
"""QDialog без системной рамки, с обводкой, заголовком, кнопкой закрытия и ресайзом."""
"""QDialog со стандартной рамкой, прокруткой содержимого и поддержкой изменения размеров."""
def __init__(self, parent: QWidget | None = None, **kwargs) -> None:
super().__init__(parent, **kwargs)
self.setWindowFlags(
Qt.WindowType.Dialog
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowMinimizeButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
| Qt.WindowType.WindowCloseButtonHint
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setMouseTracking(True)
# Внешний layout
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
# Главный макет диалога
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# Внутренний контейнер, к которому применяются стили
self._container = QWidget(self)
self._container.setObjectName("DialogContainer")
self._container.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self._container.setMouseTracking(True)
# Прокручиваемая область для содержимого
self._scroll_area = QScrollArea(self)
self._scroll_area.setWidgetResizable(True)
self._scroll_area.setFrameShape(QScrollArea.Shape.NoFrame)
self._scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
main_layout.addWidget(self._scroll_area)
outer.addWidget(self._container)
# Виджет-контейнер для содержимого
self._content_widget = QWidget()
self._scroll_area.setWidget(self._content_widget)
container_layout = QVBoxLayout(self._container)
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(0)
# ── Мини-заголовок ──────────────────────────────────────────
self._title_bar = QWidget(self._container)
self._title_bar.setObjectName("DialogTitleBar")
self._title_bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self._title_bar.setFixedHeight(32)
tb_layout = QHBoxLayout(self._title_bar)
tb_layout.setContentsMargins(10, 4, 6, 4)
tb_layout.setSpacing(4)
self._title_label = QLabel()
self._title_label.setObjectName("DialogTitle")
tb_layout.addWidget(self._title_label, 1)
self._close_btn = self._build_window_button()
self._close_btn.clicked.connect(self.close)
tb_layout.addWidget(self._close_btn)
container_layout.addWidget(self._title_bar)
# Область содержимого (sub-layout, доступен через containerLayout())
self._body = QWidget(self._container)
self._body_layout = QVBoxLayout(self._body)
self._body_layout.setContentsMargins(12, 8, 12, 12)
container_layout.addWidget(self._body, 1)
self._drag_offset = None
# ── Публичные методы ────────────────────────────────────────────
# Макет содержимого
self._body_layout = QVBoxLayout(self._content_widget)
self._body_layout.setContentsMargins(12, 12, 12, 12)
self._body_layout.setSpacing(8)
def setWindowTitle(self, title: str) -> None: # noqa: N802
super().setWindowTitle(title)
self._title_label.setText(title)
def containerLayout(self) -> QVBoxLayout:
"""Вернуть layout области содержимого (под заголовком)."""
"""Вернуть layout области содержимого (для совместимости с наследниками)."""
return self._body_layout
# ── Построение кнопки ───────────────────────────────────────────
@staticmethod
def _build_window_button() -> QToolButton:
button = QToolButton()
button.setProperty("windowControl", "close")
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setFixedSize(24, 20)
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
close_icon = icon("fa5s.times", color=ICON_COLOR_DANGER)
if not close_icon.isNull():
button.setIcon(close_icon)
else:
button.setText("×")
button.setIconSize(QSize(10, 10))
shadow = QGraphicsDropShadowEffect(button)
shadow.setBlurRadius(10)
shadow.setOffset(0, 2)
shadow.setColor(QColor(0, 0, 0, 80))
button.setGraphicsEffect(shadow)
return button
# ── Перетаскивание ───────────────────────────────────────────────
def mousePressEvent(self, event) -> None: # type: ignore[override]
if event.button() == Qt.MouseButton.LeftButton:
self._drag_offset = (
event.globalPosition().toPoint() - self.frameGeometry().topLeft()
)
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None: # type: ignore[override]
if event.buttons() & Qt.MouseButton.LeftButton and self._drag_offset is not None:
self.move(event.globalPosition().toPoint() - self._drag_offset)
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None: # type: ignore[override]
self._drag_offset = None
super().mouseReleaseEvent(event)
def sizeHint(self) -> QSize:
"""Вычислить предпочтительный размер на основе содержимого, ограничивая высоту."""
hint = self._scroll_area.widget().sizeHint()
# Добавляем небольшой запас по ширине для вертикальной полосы прокрутки
hint.setWidth(hint.width() + 18)
# Ограничиваем высоту 720 пикселями на случай больших макетов (например, тест подсветки)
if hint.height() > 720:
hint.setHeight(720)
return hint

View File

@ -1,24 +1,18 @@
from __future__ import annotations
from PySide6.QtCore import QEvent, Qt, QSize
from PySide6.QtGui import QColor
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QGraphicsDropShadowEffect,
QHBoxLayout,
QLabel,
QTabBar,
QToolButton,
QWidget,
)
from ..ui.icons import ICON_COLOR_PRIMARY, ICON_COLOR_DANGER, icon
class TitleBar(QWidget):
"""Верхняя панель вкладок, отображаемая под системным заголовком главного окна."""
def __init__(self, window: QWidget) -> None:
super().__init__(window)
self._window = window
self._drag_offset = None
self.setObjectName("TitleBar")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
@ -26,19 +20,10 @@ class TitleBar(QWidget):
self.setFixedHeight(38)
layout = QHBoxLayout(self)
layout.setContentsMargins(10, 4, 6, 4)
layout.setContentsMargins(10, 4, 10, 4)
layout.setSpacing(8)
self._layout = layout
self.icon_label = QLabel()
self.icon_label.setObjectName("TitleBarIcon")
self.icon_label.setFixedSize(16, 16)
layout.addWidget(self.icon_label)
self.title_label = QLabel()
self.title_label.setObjectName("TitleBarTitle")
layout.addWidget(self.title_label)
self.tabs_container = QWidget(self)
self.tabs_container.setObjectName("TitleTabsContainer")
self.tabs_layout = QHBoxLayout(self.tabs_container)
@ -46,78 +31,8 @@ class TitleBar(QWidget):
self.tabs_layout.setSpacing(0)
layout.addWidget(self.tabs_container, 1)
self.min_btn = self._build_window_button("—", "minimize")
self.min_btn.clicked.connect(self._window.showMinimized)
layout.addWidget(self.min_btn)
self.max_btn = self._build_window_button("□", "maximize")
self.max_btn.clicked.connect(self._toggle_max_restore)
layout.addWidget(self.max_btn)
self.close_btn = self._build_window_button("×", "close")
self.close_btn.clicked.connect(self._window.close)
layout.addWidget(self.close_btn)
self._window.installEventFilter(self)
self._sync_window_meta()
def _build_window_button(self, text: str, role: str) -> QToolButton:
button = QToolButton(self)
button.setText(text)
button.setProperty("windowControl", role)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setFixedSize(30, 24)
button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
if role == "minimize":
minimize_icon = icon("fa5s.minus", color=ICON_COLOR_PRIMARY)
if not minimize_icon.isNull():
button.setIcon(minimize_icon)
button.setText("")
elif role == "maximize":
maximize_icon = icon("fa5s.square", color=ICON_COLOR_PRIMARY)
if not maximize_icon.isNull():
button.setIcon(maximize_icon)
button.setText("")
elif role == "close":
close_icon = icon("fa5s.times", color=ICON_COLOR_DANGER)
if not close_icon.isNull():
button.setIcon(close_icon)
button.setText("")
button.setIconSize(QSize(12, 12))
shadow = QGraphicsDropShadowEffect(button)
shadow.setBlurRadius(10)
shadow.setOffset(0, 2)
shadow.setColor(QColor(0, 0, 0, 80))
button.setGraphicsEffect(shadow)
return button
def _sync_window_meta(self) -> None:
self.title_label.setText(self._window.windowTitle())
window_icon = self._window.windowIcon()
if not window_icon.isNull():
self.icon_label.setPixmap(window_icon.pixmap(16, 16))
max_icon = icon(
"fa5s.clone" if self._window.isMaximized() else "fa5s.square",
color=ICON_COLOR_PRIMARY,
)
if not max_icon.isNull():
self.max_btn.setIcon(max_icon)
self.max_btn.setText("")
else:
self.max_btn.setText("❐" if self._window.isMaximized() else "□")
def _toggle_max_restore(self) -> None:
if self._window.isMaximized():
self._window.showNormal()
else:
self._window.showMaximized()
self._sync_window_meta()
def set_tabs_widget(self, widget: QWidget) -> None:
"""Встроить виджет вкладок в панель."""
while self.tabs_layout.count():
item = self.tabs_layout.takeAt(0)
if item is not None:
@ -125,62 +40,3 @@ class TitleBar(QWidget):
if old_widget is not None:
old_widget.setParent(None)
self.tabs_layout.addWidget(widget)
# Перехватываем события мыши на QTabBar для перетаскивания
# за пустую область вкладок
widget.installEventFilter(self)
def eventFilter(self, watched, event): # type: ignore[override]
if watched is self._window and event.type() in (
QEvent.Type.WindowTitleChange,
QEvent.Type.WindowIconChange,
QEvent.Type.WindowStateChange,
):
self._sync_window_meta()
# Перетаскивание окна за пустую область QTabBar
if isinstance(watched, QTabBar):
etype = event.type()
if etype == QEvent.Type.MouseButtonPress:
if event.button() == Qt.MouseButton.LeftButton:
if watched.tabAt(event.position().toPoint()) == -1:
self._drag_offset = (
event.globalPosition().toPoint()
- self._window.frameGeometry().topLeft()
)
return True
elif etype == QEvent.Type.MouseMove:
if event.buttons() & Qt.MouseButton.LeftButton and self._drag_offset is not None:
if not self._window.isMaximized():
self._window.move(event.globalPosition().toPoint() - self._drag_offset)
return True
elif etype == QEvent.Type.MouseButtonRelease:
if self._drag_offset is not None:
self._drag_offset = None
return True
elif etype == QEvent.Type.MouseButtonDblClick:
if event.button() == Qt.MouseButton.LeftButton:
if watched.tabAt(event.position().toPoint()) == -1:
self._toggle_max_restore()
return True
return super().eventFilter(watched, event)
def mousePressEvent(self, event) -> None: # type: ignore[override]
if event.button() == Qt.MouseButton.LeftButton:
self._drag_offset = event.globalPosition().toPoint() - self._window.frameGeometry().topLeft()
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None: # type: ignore[override]
if event.buttons() & Qt.MouseButton.LeftButton and self._drag_offset is not None:
if not self._window.isMaximized():
self._window.move(event.globalPosition().toPoint() - self._drag_offset)
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None: # type: ignore[override]
self._drag_offset = None
super().mouseReleaseEvent(event)
def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override]
if event.button() == Qt.MouseButton.LeftButton:
self._toggle_max_restore()
super().mouseDoubleClickEvent(event)