from __future__ import annotations from pathlib import Path from PySide6.QtCore import QThread, QTimer, Qt, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QFrame, QGraphicsDropShadowEffect, QHBoxLayout, QLabel, QMessageBox, QPushButton, QVBoxLayout, QWidget, ) from .. import __version__ from ..services.update_service import ( UpdateCancelled, UpdateInfo, check_for_update, current_executable, download_update, launch_update, ) from ..ui.icons import ICON_COLOR_PRIMARY, get_icon_color, icon from .dialogs.commands_dialog import CommandsDialog from .dialogs.settings_dialog import SettingsDialog class StartTab(QWidget): def __init__(self, on_new_connection) -> None: super().__init__() self._open_dialog_windows: dict[str, QWidget] = {} self._available_update: UpdateInfo | None = None self._update_check_thread: _UpdateCheckThread | None = None self._update_download_thread: _UpdateDownloadThread | None = None layout = QVBoxLayout(self) layout.setContentsMargins(26, 26, 26, 26) layout.addStretch(1) hero_card = QFrame() hero_card.setObjectName("HeroCard") hero_card.setMaximumWidth(560) shadow = QGraphicsDropShadowEffect(self) shadow.setBlurRadius(28) shadow.setOffset(0, 8) shadow.setColor(QColor(20, 30, 50, 45)) hero_card.setGraphicsEffect(shadow) hero_layout = QVBoxLayout(hero_card) hero_layout.setContentsMargins(28, 28, 28, 28) hero_layout.setSpacing(14) badge = QLabel("Plate Config") badge.setObjectName("HeroBadge") badge.setAlignment(Qt.AlignmentFlag.AlignCenter) hero_layout.addWidget(badge, alignment=Qt.AlignmentFlag.AlignHCenter) title = QLabel("Подключение к COM-плате") title.setObjectName("HeroTitle") title.setAlignment(Qt.AlignmentFlag.AlignCenter) hero_layout.addWidget(title) hint = QLabel("Откройте новое соединение и настройте параметры порта для работы с платой.") hint.setObjectName("HeroHint") hint.setAlignment(Qt.AlignmentFlag.AlignCenter) hint.setWordWrap(True) hero_layout.addWidget(hint) btn = QPushButton("Новое соединение") btn.setProperty("role", "primary") btn.setMinimumWidth(210) btn.setMinimumHeight(40) btn.setIcon(icon("fa5s.plus-circle", color=ICON_COLOR_PRIMARY)) btn.clicked.connect(on_new_connection) hero_layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignHCenter) card_wrap = QHBoxLayout() card_wrap.addStretch(1) card_wrap.addWidget(hero_card) card_wrap.addStretch(1) layout.addLayout(card_wrap) # ── Кнопки «Команды платы» и «Настройки» ─────────────────── bottom_row = QHBoxLayout() bottom_row.setSpacing(12) bottom_row.addStretch(1) self._commands_btn = QPushButton("Команды платы") self._commands_btn.setMinimumHeight(38) self._commands_btn.setMinimumWidth(180) self._commands_btn.setIcon(icon("fa5s.book", color=get_icon_color())) self._commands_btn.clicked.connect(self._open_commands) bottom_row.addWidget(self._commands_btn) settings_column = QWidget() self._settings_column_layout = QVBoxLayout(settings_column) self._settings_column_layout.setContentsMargins(0, 0, 0, 0) self._settings_column_layout.setSpacing(8) self._settings_btn = QPushButton("Настройки приложения") self._settings_btn.setMinimumHeight(38) self._settings_btn.setMinimumWidth(180) self._settings_btn.setIcon(icon("fa5s.cog", color=get_icon_color())) self._settings_btn.clicked.connect(self._open_settings) self._settings_column_layout.addWidget(self._settings_btn) self._update_btn = QPushButton("Скачать обновление") self._update_btn.setProperty("role", "primary") self._update_btn.setMinimumHeight(38) self._update_btn.setMinimumWidth(180) self._update_btn.setIcon(icon("fa5s.download", color=ICON_COLOR_PRIMARY)) self._update_btn.clicked.connect(self._start_update_download) self._update_btn.hide() self._settings_column_layout.addWidget(self._update_btn) bottom_row.addWidget(settings_column, alignment=Qt.AlignmentFlag.AlignTop) bottom_row.addStretch(1) layout.addLayout(bottom_row) layout.addStretch(2) QTimer.singleShot(500, self._start_update_check) # ── Public ──────────────────────────────────────────────────── def refresh_icons(self) -> None: """Re-create icons with the current theme colour.""" clr = get_icon_color() self._commands_btn.setIcon(icon("fa5s.book", color=clr)) self._settings_btn.setIcon(icon("fa5s.cog", color=clr)) self._update_btn.setIcon(icon("fa5s.download", color=ICON_COLOR_PRIMARY)) def shutdown(self) -> None: """Остановить фоновые задачи перед закрытием приложения.""" for thread in (self._update_check_thread, self._update_download_thread): if thread is not None and thread.isRunning(): thread.requestInterruption() thread.wait(35_000) # ── Handlers ────────────────────────────────────────────────── def _on_dialog_destroyed(self, key: str) -> None: self._open_dialog_windows.pop(key, None) def _open_single_dialog(self, key: str, factory) -> None: existing = self._open_dialog_windows.get(key) if existing is not None: try: existing.show() existing.raise_() existing.activateWindow() return except RuntimeError: self._open_dialog_windows.pop(key, None) dlg = factory() dlg.setModal(False) dlg.setWindowModality(Qt.WindowModality.NonModal) dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) dlg.destroyed.connect(lambda _obj=None, k=key: self._on_dialog_destroyed(k)) self._open_dialog_windows[key] = dlg dlg.show() dlg.raise_() dlg.activateWindow() def _open_commands(self) -> None: self._open_single_dialog("commands", lambda: CommandsDialog(self)) def _open_settings(self) -> None: self._open_single_dialog("settings", lambda: SettingsDialog(self)) # ── Обновление приложения ───────────────────────────────────── def _start_update_check(self) -> None: if self._update_check_thread is not None: return thread = _UpdateCheckThread(parent=self) thread.result_ready.connect(self._on_update_check_done) thread.finished.connect(self._cleanup_update_check) self._update_check_thread = thread thread.start() def _on_update_check_done(self, info: UpdateInfo | None) -> None: self._available_update = info if info is None: self._update_btn.hide() return self._update_btn.setText("Скачать обновление") self._update_btn.setToolTip(f"Доступна версия {info.tag}") self._update_btn.setEnabled(True) self._update_btn.show() def _cleanup_update_check(self) -> None: if self._update_check_thread is not None: self._update_check_thread.deleteLater() self._update_check_thread = None def _start_update_download(self) -> None: info = self._available_update if info is None or self._update_download_thread is not None: return try: target_directory = current_executable().parent except Exception as exc: QMessageBox.information(self, "Обновление приложения", str(exc)) return self._update_btn.setEnabled(False) self._update_btn.setText("Скачивание 0%") thread = _UpdateDownloadThread(info, target_directory, parent=self) thread.progress_changed.connect(self._on_update_progress) thread.result_ready.connect(self._on_update_downloaded) thread.failed.connect(self._on_update_failed) thread.finished.connect(self._cleanup_update_download) self._update_download_thread = thread thread.start() def _on_update_progress(self, percent: int) -> None: self._update_btn.setText(f"Скачивание {percent}%") def _on_update_downloaded(self, path: Path) -> None: self._update_btn.setText("Перезапуск…") try: launch_update(path) except Exception as exc: path.unlink(missing_ok=True) self._on_update_failed(str(exc)) return self.window().close() def _on_update_failed(self, detail: str) -> None: self._update_btn.setText("Скачать обновление") self._update_btn.setEnabled(True) QMessageBox.warning( self, "Обновление приложения", f"Не удалось установить обновление:\n{detail}", ) def _cleanup_update_download(self) -> None: if self._update_download_thread is not None: self._update_download_thread.deleteLater() self._update_download_thread = None class _UpdateCheckThread(QThread): result_ready = Signal(object) def run(self) -> None: try: info = check_for_update(__version__) except Exception: info = None if not self.isInterruptionRequested(): self.result_ready.emit(info) class _UpdateDownloadThread(QThread): progress_changed = Signal(int) result_ready = Signal(object) failed = Signal(str) def __init__( self, info: UpdateInfo, target_directory: Path, parent: QWidget | None = None, ) -> None: super().__init__(parent) self._info = info self._target_directory = target_directory def run(self) -> None: try: path = download_update( self._info, self._target_directory, progress=self.progress_changed.emit, cancelled=self.isInterruptionRequested, ) except UpdateCancelled: return except Exception as exc: if not self.isInterruptionRequested(): self.failed.emit(str(exc)) return if not self.isInterruptionRequested(): self.result_ready.emit(path)