diff --git a/app/entry.py b/app/entry.py index ac2a8c1..c0d2714 100644 --- a/app/entry.py +++ b/app/entry.py @@ -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(): diff --git a/app/main_window.py b/app/main_window.py index 8ab3d0e..8a2269e 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -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.""" diff --git a/app/ui/theme.py b/app/ui/theme.py index 3a77628..e6d5d66 100644 --- a/app/ui/theme.py +++ b/app/ui/theme.py @@ -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-стрелки diff --git a/app/widgets/dialogs/led_test_dialog.py b/app/widgets/dialogs/led_test_dialog.py index 7cc22d3..042c480 100644 --- a/app/widgets/dialogs/led_test_dialog.py +++ b/app/widgets/dialogs/led_test_dialog.py @@ -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: diff --git a/app/widgets/dialogs/styled_dialog.py b/app/widgets/dialogs/styled_dialog.py index 7e99209..336aca8 100644 --- a/app/widgets/dialogs/styled_dialog.py +++ b/app/widgets/dialogs/styled_dialog.py @@ -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 diff --git a/app/widgets/title_bar.py b/app/widgets/title_bar.py index 6d8721a..6ef6be1 100644 --- a/app/widgets/title_bar.py +++ b/app/widgets/title_bar.py @@ -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)