diff --git a/AGENTS.md b/AGENTS.md index b442c82..5b88636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md -This repository doesn't contain any agent specific instructions other than its README.md and its linked resources. +This repository doesn't contain any agent specific instructions other than its [README.md](README.md) and its linked resources. diff --git a/pdm.lock b/pdm.lock index e73f74b..7176831 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "dev", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:7c9f1e0616bf21c1674750564d9b957eac025cc24306614309b43c098f5ddffb" +content_hash = "sha256:a11fee70f025f3a84c850cd7cc7fd3629253f7883281b29ce63f141a6147c4f3" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev51" +version = "0.2.1.dev52" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev51-py3-none-any.whl", hash = "sha256:9e41486847a583a2d731db6f1f62d97930577757ca09422022e3d82c63c964aa"}, - {file = "porringer-0.2.1.dev51.tar.gz", hash = "sha256:a2ffe2da0bbc729ea5b052a5b46080014cea079fb665b9518b40ad6d65d6155e"}, + {file = "porringer-0.2.1.dev52-py3-none-any.whl", hash = "sha256:266c37fb3e338fe4186420db9dc0f30bc0642dc8bab1bb8c1804d366b9d74274"}, + {file = "porringer-0.2.1.dev52.tar.gz", hash = "sha256:84dddf11ecd20696f64ca5b16b27583bde79df6042a37a9c679d9029ba0b6bde"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 446ddec..90c7ccf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev51", + "porringer>=0.2.1.dev52", "qasync>=0.28.0", "velopack>=0.0.1442.dev64255", "typer>=0.24.1", diff --git a/synodic_client/application/screen/action_card.py b/synodic_client/application/screen/action_card.py index 100f336..9919424 100644 --- a/synodic_client/application/screen/action_card.py +++ b/synodic_client/application/screen/action_card.py @@ -297,6 +297,9 @@ def _build_top_row(self) -> QHBoxLayout: self._package_label = QLabel() self._package_label.setStyleSheet(ACTION_CARD_PACKAGE_STYLE) + self._package_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse, + ) top.addWidget(self._package_label) top.addStretch() @@ -327,6 +330,9 @@ def _build_description_row(self) -> QLabel: self._desc_label = QLabel() self._desc_label.setStyleSheet(ACTION_CARD_DESC_STYLE) self._desc_label.setWordWrap(True) + self._desc_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse, + ) return self._desc_label def _build_command_row(self) -> QWidget: @@ -377,9 +383,13 @@ def mousePressEvent(self, event: object) -> None: # noqa: N802 """Toggle the inline log body on click.""" if self._is_skeleton or not hasattr(self, '_log_output'): return - # Don't toggle the log when clicking the copy button + # Don't toggle the log when clicking interactive child widgets if hasattr(self, '_copy_btn') and self._copy_btn.underMouse(): return + if hasattr(self, '_package_label') and self._package_label.underMouse(): + return + if hasattr(self, '_desc_label') and self._desc_label.underMouse(): + return self._toggle_log() def _toggle_log(self) -> None: @@ -560,6 +570,12 @@ def set_check_result(self, result: SetupActionResult) -> None: self._status_label.setText(label) self._status_label.setStyleSheet(ACTION_CARD_STATUS_NEEDED) + # Surface diagnostic detail (e.g. SCM URL mismatch) as a tooltip + if result.message: + self._status_label.setToolTip(result.message) + else: + self._status_label.setToolTip('') + # Version column self._check_available_version = result.available_version if result.installed_version and result.available_version: diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index a574abd..f816c47 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -38,6 +38,7 @@ normalize_manifest_key, ) from synodic_client.application.screen.spinner import SpinnerWidget +from synodic_client.application.screen.update_banner import UpdateBanner from synodic_client.application.theme import ( CARD_SPACING, COMPACT_MARGINS, @@ -815,6 +816,9 @@ def __init__( self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE) self.setWindowIcon(app_icon()) + # Update banner — always available, starts hidden. + self._update_banner = UpdateBanner(self) + @property def porringer(self) -> API | None: """Return the porringer API instance, if available.""" @@ -825,6 +829,11 @@ def plugins_view(self) -> PluginsView | None: """Return the plugins view, if initialised.""" return self._plugins_view + @property + def update_banner(self) -> UpdateBanner: + """Return the update banner widget.""" + return self._update_banner + def show(self) -> None: """Show the window, initializing UI lazily on first show.""" if self._tabs is None and self._porringer is not None and self._config is not None: @@ -843,7 +852,14 @@ def show(self) -> None: gear_btn.clicked.connect(self.settings_requested.emit) self._tabs.setCornerWidget(gear_btn) - self.setCentralWidget(self._tabs) + # Container: banner above tabs + container = QWidget(self) + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(0) + container_layout.addWidget(self._update_banner) + container_layout.addWidget(self._tabs) + self.setCentralWidget(container) # Paint the window immediately, then refresh data asynchronously super().show() diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index 78042ba..a031e1e 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -224,6 +224,11 @@ def set_update_status(self, text: str) -> None: """Set the inline status text next to the *Check for Updates* button.""" self._update_status_label.setText(text) + def set_checking(self) -> None: + """Enter the *checking* state — disable button and show status.""" + self._check_updates_btn.setEnabled(False) + self._update_status_label.setText('Checking\u2026') + def reset_check_updates_button(self) -> None: """Re-enable the *Check for Updates* button after a check completes.""" self._check_updates_btn.setEnabled(True) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 5c7bf23..7e0f3aa 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -10,8 +10,6 @@ from PySide6.QtWidgets import ( QApplication, QMenu, - QMessageBox, - QProgressDialog, QSystemTrayIcon, ) @@ -56,15 +54,11 @@ def __init__( self._config = config self._runner: QThread | None = None self._tool_runner: QThread | None = None - self._progress_dialog: QProgressDialog | None = None - self._pending_update_info: UpdateInfo | None = None - self._download_cancelled = False self.tray_icon = app_icon() self.tray = QSystemTrayIcon() self.tray.setIcon(self.tray_icon) - self.tray.messageClicked.connect(self._on_notification_clicked) self.tray.activated.connect(self._on_tray_activated) self.tray.setVisible(True) @@ -92,6 +86,11 @@ def __init__( plugins_view.update_all_requested.connect(self._on_tool_update) plugins_view.plugin_update_requested.connect(self._on_single_plugin_update) + # Connect update banner signals + self._banner = window.update_banner + self._banner.restart_requested.connect(self._apply_update) + self._banner.retry_requested.connect(lambda: self._do_check_updates(silent=True)) + def _build_menu(self, app: QApplication, window: MainWindow) -> None: """Build the tray context menu.""" self.menu = QMenu() @@ -216,12 +215,6 @@ def _reset_update_action(self) -> None: self.update_action.setEnabled(True) self.update_action.setText('Check for Updates...') - def _close_progress(self) -> None: - """Close and discard the download progress dialog, if open.""" - if self._progress_dialog: - self._progress_dialog.close() - self._progress_dialog = None - def _on_check_updates(self) -> None: """Handle manual check for updates action.""" self._do_check_updates(silent=False) @@ -230,7 +223,7 @@ def _on_auto_check_updates(self) -> None: """Handle automatic (periodic) check for updates. Failures and no-update results are logged silently without - showing Windows notifications. + showing the in-app error banner. """ self._do_check_updates(silent=True) @@ -238,24 +231,19 @@ def _do_check_updates(self, *, silent: bool) -> None: """Run an update check. Args: - silent: When ``True``, suppress notifications for failures - and no-update results. Notifications are still shown - when an update *is* available. + silent: When ``True``, suppress the in-app error banner + for failures and no-update results. The banner is + always shown when an update *is* available. """ if self._client.updater is None: if not silent: - self.tray.showMessage( - 'Update Error', - 'Updater is not initialized.', - QSystemTrayIcon.MessageIcon.Warning, - ) + self._banner.show_error('Updater is not initialized.') return # Disable both the tray action and the settings button while checking self.update_action.setEnabled(False) self.update_action.setText('Checking for Updates...') - self._settings_window._check_updates_btn.setEnabled(False) - self._settings_window.set_update_status('Checking\u2026') + self._settings_window.set_checking() worker = UpdateCheckWorker(self._client) worker.finished.connect(lambda result: self._on_update_check_finished(result, silent=silent)) @@ -272,11 +260,7 @@ def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = if result is None: self._settings_window.set_update_status('Check failed') if not silent: - self.tray.showMessage( - 'Update Check Failed', - 'Failed to check for updates. Please try again later.', - QSystemTrayIcon.MessageIcon.Warning, - ) + self._banner.show_error('Failed to check for updates.') else: logger.warning('Automatic update check failed (no result)') return @@ -284,14 +268,7 @@ def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = if result.error: self._settings_window.set_update_status(result.error) if not silent: - # Distinguish informational messages (no releases for channel) - # from genuine failures. - is_no_releases = 'No releases found' in result.error - title = 'No Updates Available' if is_no_releases else 'Update Check Failed' - icon = ( - QSystemTrayIcon.MessageIcon.Information if is_no_releases else QSystemTrayIcon.MessageIcon.Warning - ) - self.tray.showMessage(title, result.error, icon) + self._banner.show_error(result.error) else: logger.warning('Automatic update check failed: %s', result.error) return @@ -301,25 +278,16 @@ def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = f'Up to date ({result.current_version})', ) if not silent: - self.tray.showMessage( - 'No Updates Available', - f'You are running the latest version ({result.current_version}).', - QSystemTrayIcon.MessageIcon.Information, - ) + logger.info('No updates available (current: %s)', result.current_version) else: logger.debug('Automatic update check: no update available') return - # Update available - always show notification, clicking it starts download - self._pending_update_info = result - self._settings_window.set_update_status( - f'Update available: {result.latest_version}', - ) - self.tray.showMessage( - 'Update Available', - f'Version {result.latest_version} is available (current: {result.current_version}).\nClick to download.', - QSystemTrayIcon.MessageIcon.Information, - ) + # Update available — show banner and start download automatically + version = str(result.latest_version) + self._settings_window.set_update_status(f'Update available: {version}') + self._banner.show_downloading(version) + self._start_download(version) def _on_update_check_error(self, error: str, *, silent: bool = False) -> None: """Handle update check error.""" @@ -328,11 +296,7 @@ def _on_update_check_error(self, error: str, *, silent: bool = False) -> None: self._settings_window.set_update_status(f'Error: {error}') if not silent: - self.tray.showMessage( - 'Update Check Error', - f'An error occurred: {error}', - QSystemTrayIcon.MessageIcon.Critical, - ) + self._banner.show_error(f'Update check error: {error}') else: logger.warning('Automatic update check error: %s', error) @@ -393,107 +357,44 @@ def _on_tool_update_error(self, error: str) -> None: QSystemTrayIcon.MessageIcon.Warning, ) - def _on_notification_clicked(self) -> None: - """Handle notification click - starts download if update is pending.""" - if self._pending_update_info is not None and self._pending_update_info.available: - self._pending_update_info = None - self._start_download() - - def _start_download(self) -> None: - """Start downloading the update.""" - # Create progress dialog - self._progress_dialog = QProgressDialog( - 'Downloading update...', - 'Cancel', - 0, - 100, - self._window, - ) - self._progress_dialog.setWindowTitle('Downloading Update') - self._progress_dialog.setAutoClose(False) - self._progress_dialog.setAutoReset(False) - self._progress_dialog.canceled.connect(self._on_download_cancelled) - self._download_cancelled = False - self._progress_dialog.show() + # -- Self-update download & apply -- + def _start_download(self, version: str) -> None: + """Start downloading the update in the background. + + Args: + version: The version string being downloaded (for banner display). + """ worker = UpdateDownloadWorker(self._client) - worker.finished.connect(self._on_download_finished) - worker.progress.connect(self._on_download_progress) + worker.finished.connect(lambda success: self._on_download_finished(success, version)) + worker.progress.connect(self._banner.show_downloading_progress) worker.error.connect(self._on_download_error) self._runner = worker self._runner.start() - def _on_download_cancelled(self) -> None: - """Handle cancel button on the download progress dialog.""" - self._download_cancelled = True - self._close_progress() - logger.info('Update download cancelled by user') - - def _on_download_progress(self, percentage: int) -> None: - """Handle download progress update.""" - if self._progress_dialog and not self._download_cancelled: - self._progress_dialog.setValue(percentage) - self._progress_dialog.setLabelText(f'Downloading update... {percentage}%') - - def _on_download_finished(self, success: bool) -> None: - """Handle download completion.""" - self._close_progress() - - if self._download_cancelled: - return - + def _on_download_finished(self, success: bool, version: str) -> None: + """Handle download completion — transition banner to ready state.""" if not success: - self.tray.showMessage( - 'Download Failed', - 'Failed to download the update. Please try again later.', - QSystemTrayIcon.MessageIcon.Warning, - ) + self._banner.show_error('Download failed. Please try again later.') return - # Prompt to apply update - keep as dialog since it needs user choice - reply = QMessageBox.question( - self._window if self._window.isVisible() else None, - 'Download Complete', - 'The update has been downloaded.\n\n' - 'Would you like to install it now?\n' - 'The application will restart after installation.', - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.Yes, - ) - - if reply == QMessageBox.StandardButton.Yes: - self._apply_update() + self._banner.show_ready(version) + self._settings_window.set_update_status(f'Ready to install: {version}') def _on_download_error(self, error: str) -> None: - """Handle download error.""" - self._close_progress() - - self.tray.showMessage( - 'Download Error', - f'An error occurred while downloading: {error}', - QSystemTrayIcon.MessageIcon.Critical, - ) + """Handle download error — show error banner.""" + self._banner.show_error(f'Download error: {error}') def _apply_update(self) -> None: - """Apply the downloaded update.""" + """Apply the downloaded update and restart.""" if self._client.updater is None: return try: - # Schedule update to apply on exit, then quit the app self._client.apply_update_on_exit(restart=True) - - self.tray.showMessage( - 'Update Ready', - 'The update will be applied when the application closes.\nThe application will restart automatically.', - QSystemTrayIcon.MessageIcon.Information, - ) + logger.info('Update scheduled — restarting application') self._app.quit() - except Exception as e: - self.tray.showMessage( - 'Update Failed', - f'Failed to apply the update: {e}', - QSystemTrayIcon.MessageIcon.Warning, - ) + logger.error('Failed to apply update: %s', e) + self._banner.show_error(f'Failed to apply update: {e}') diff --git a/synodic_client/application/screen/update_banner.py b/synodic_client/application/screen/update_banner.py new file mode 100644 index 0000000..a32b9a9 --- /dev/null +++ b/synodic_client/application/screen/update_banner.py @@ -0,0 +1,305 @@ +"""In-app update banner for the self-update lifecycle. + +Replaces Windows tray balloon notifications and modal dialogs with a +persistent, non-intrusive banner displayed at the top of the main +window. The banner transitions through three visual states: + +* **downloading** — update detected, auto-downloading in the background. +* **ready** — download complete; user can restart at their convenience. +* **error** — check or download failed with a retry option. + +The banner slides in/out using a ``QPropertyAnimation`` on +``maximumHeight`` for a polished feel. +""" + +from __future__ import annotations + +import logging +from enum import Enum, auto + +from PySide6.QtCore import ( + QEasingCurve, + QPropertyAnimation, + Qt, + QTimer, + Signal, +) +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QProgressBar, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from synodic_client.application.theme import ( + UPDATE_BANNER_ANIMATION_MS, + UPDATE_BANNER_BTN_STYLE, + UPDATE_BANNER_DISMISS_STYLE, + UPDATE_BANNER_ERROR_DISMISS_MS, + UPDATE_BANNER_ERROR_STYLE, + UPDATE_BANNER_MESSAGE_STYLE, + UPDATE_BANNER_PROGRESS_STYLE, + UPDATE_BANNER_READY_STYLE, + UPDATE_BANNER_STYLE, + UPDATE_BANNER_VERSION_STYLE, +) + +logger = logging.getLogger(__name__) + + +class UpdateBannerState(Enum): + """Visual states for the update banner.""" + + HIDDEN = auto() + DOWNLOADING = auto() + READY = auto() + ERROR = auto() + + +# Height of the banner content (progress variant is slightly taller). +_BANNER_HEIGHT = 38 +_BANNER_HEIGHT_WITH_PROGRESS = 44 + + +class UpdateBanner(QFrame): + """Non-intrusive in-app banner for the self-update lifecycle. + + Signals: + restart_requested: User clicked "Restart Now". + retry_requested: User clicked "Retry" on an error banner. + dismissed: User clicked the dismiss (×) button. + """ + + restart_requested = Signal() + retry_requested = Signal() + dismissed = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName('updateBanner') + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + self._state = UpdateBannerState.HIDDEN + self._target_version: str = '' + + # --- Layout --- + self._outer = QVBoxLayout(self) + self._outer.setContentsMargins(0, 0, 0, 0) + self._outer.setSpacing(0) + + # Row: icon · message · [progress_label] · [action_btn] · dismiss + self._row = QHBoxLayout() + self._row.setContentsMargins(12, 6, 12, 4) + self._row.setSpacing(8) + self._outer.addLayout(self._row) + + self._icon_label = QLabel('\U0001f504') # 🔄 + self._icon_label.setFixedWidth(18) + self._row.addWidget(self._icon_label) + + self._message = QLabel() + self._message.setStyleSheet(UPDATE_BANNER_MESSAGE_STYLE) + self._row.addWidget(self._message) + + self._row.addStretch() + + self._action_btn = QPushButton() + self._action_btn.setStyleSheet(UPDATE_BANNER_BTN_STYLE) + self._action_btn.clicked.connect(self._on_action) + self._action_btn.hide() + self._row.addWidget(self._action_btn) + + self._dismiss_btn = QPushButton('\u00d7') # × + self._dismiss_btn.setStyleSheet(UPDATE_BANNER_DISMISS_STYLE) + self._dismiss_btn.setFixedWidth(24) + self._dismiss_btn.clicked.connect(self._on_dismiss) + self._row.addWidget(self._dismiss_btn) + + # Thin progress bar (only visible during download) + self._progress = QProgressBar() + self._progress.setStyleSheet(UPDATE_BANNER_PROGRESS_STYLE) + self._progress.setTextVisible(False) + self._progress.setRange(0, 0) # indeterminate + self._progress.setFixedHeight(3) + self._progress.hide() + self._outer.addWidget(self._progress) + + # Start fully collapsed + self.setMaximumHeight(0) + self.setVisible(False) + + # Animation for slide-in / slide-out + self._anim = QPropertyAnimation(self, b'maximumHeight') + self._anim.setEasingCurve(QEasingCurve.Type.OutCubic) + self._anim.setDuration(UPDATE_BANNER_ANIMATION_MS) + + # --- Public API --- + + @property + def state(self) -> UpdateBannerState: + """Current visual state of the banner.""" + return self._state + + def show_downloading(self, version: str) -> None: + """Transition to the *downloading* state. + + Args: + version: The version string being downloaded (e.g. ``"0.0.1.dev35"``). + """ + self._configure( + state=UpdateBannerState.DOWNLOADING, + version=version, + style=UPDATE_BANNER_STYLE, + icon='\u2b07', + text=f'Downloading update {version}\u2026', + text_style=UPDATE_BANNER_MESSAGE_STYLE, + show_progress=True, + ) + + def show_downloading_progress(self, percentage: int) -> None: + """Update the progress bar during download. + + Args: + percentage: Download progress 0–100. + """ + if self._state != UpdateBannerState.DOWNLOADING: + return + if self._progress.maximum() == 0: + # Switch from indeterminate to determinate on first real value + self._progress.setRange(0, 100) + self._progress.setValue(percentage) + + def show_ready(self, version: str) -> None: + """Transition to the *ready* state. + + Args: + version: The version that is ready to install. + """ + self._configure( + state=UpdateBannerState.READY, + version=version, + style=UPDATE_BANNER_READY_STYLE, + icon='\u2705', + text=f'Update {version} is ready \u2014 restart to finish installing', + text_style=UPDATE_BANNER_VERSION_STYLE, + action_label='Restart Now', + ) + + def show_error(self, message: str) -> None: + """Transition to the *error* state. + + Args: + message: Human-readable error description. + """ + self._configure( + state=UpdateBannerState.ERROR, + style=UPDATE_BANNER_ERROR_STYLE, + icon='\u26a0', + text=message, + text_style=UPDATE_BANNER_MESSAGE_STYLE, + action_label='Retry', + ) + QTimer.singleShot(UPDATE_BANNER_ERROR_DISMISS_MS, self._auto_dismiss_error) + + def hide_banner(self) -> None: + """Slide the banner out and reset to hidden.""" + if self._state == UpdateBannerState.HIDDEN: + return + self._state = UpdateBannerState.HIDDEN + self._slide_out() + + # --- Internal --- + + def _configure( + self, + *, + state: UpdateBannerState, + style: str, + icon: str, + text: str, + text_style: str, + version: str = '', + action_label: str = '', + show_progress: bool = False, + ) -> None: + """Apply common visual configuration and slide the banner in. + + Args: + state: The new banner state. + style: QSS for the banner frame. + icon: Single character displayed as the leading icon. + text: Message (may contain HTML). + text_style: QSS for the message label. + version: Version string to store (optional). + action_label: Text for the action button; hidden when empty. + show_progress: Whether to show the progress bar. + """ + self._state = state + self._target_version = version + + self.setStyleSheet(style) + self._icon_label.setText(icon) + self._message.setText(text) + self._message.setStyleSheet(text_style) + + if action_label: + self._action_btn.setText(action_label) + self._action_btn.show() + else: + self._action_btn.hide() + + if show_progress: + self._progress.setRange(0, 0) # indeterminate + self._progress.show() + else: + self._progress.hide() + + target_height = _BANNER_HEIGHT_WITH_PROGRESS if show_progress else _BANNER_HEIGHT + self._slide_in(target_height) + + def _slide_in(self, target_height: int) -> None: + """Animate the banner from collapsed to *target_height*.""" + self.setVisible(True) + self._anim.stop() + self._anim.setStartValue(self.maximumHeight()) + self._anim.setEndValue(target_height) + self._anim.start() + + def _slide_out(self) -> None: + """Animate the banner down to zero height, then hide.""" + self._anim.stop() + self._anim.setStartValue(self.maximumHeight()) + self._anim.setEndValue(0) + # Use a one-shot connection to avoid accumulating slots. + self._anim.finished.connect( + self._on_slide_out_done, + type=Qt.ConnectionType.SingleShotConnection, + ) + self._anim.start() + + def _on_slide_out_done(self) -> None: + """Hide the widget once the slide-out animation completes.""" + if self._state == UpdateBannerState.HIDDEN: + self.setVisible(False) + + def _on_action(self) -> None: + """Handle the primary action button click.""" + if self._state == UpdateBannerState.READY: + self.restart_requested.emit() + elif self._state == UpdateBannerState.ERROR: + self.hide_banner() + self.retry_requested.emit() + + def _on_dismiss(self) -> None: + """Handle the dismiss (×) button click.""" + self.hide_banner() + self.dismissed.emit() + + def _auto_dismiss_error(self) -> None: + """Auto-dismiss the error banner if it's still showing.""" + if self._state == UpdateBannerState.ERROR: + self.hide_banner() diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index 303016d..e50e50f 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -286,3 +286,73 @@ 'QPushButton:hover { background: palette(midlight); border-radius: 3px; }' ) """Gear button style for the MainWindow tab corner widget.""" + +# --------------------------------------------------------------------------- +# Update banner (in-app self-update notification) +# --------------------------------------------------------------------------- +UPDATE_BANNER_ANIMATION_MS = 250 +"""Duration of the slide-in / slide-out animation (ms).""" + +UPDATE_BANNER_ERROR_DISMISS_MS = 10000 +"""Auto-dismiss delay for the error banner (ms).""" + +UPDATE_BANNER_STYLE = ( + 'QFrame#updateBanner { background: #1e3a5f; border-bottom: 1px solid #2a5a8f; padding: 6px 12px;}' +) +"""Default banner style — subtle blue tint for downloading state.""" + +UPDATE_BANNER_READY_STYLE = ( + 'QFrame#updateBanner { background: #1e3f2e; border-bottom: 1px solid #2a6f3f; padding: 6px 12px;}' +) +"""Green-tinted banner for "ready to restart" state.""" + +UPDATE_BANNER_ERROR_STYLE = ( + 'QFrame#updateBanner { background: #3f1e1e; border-bottom: 1px solid #6f2a2a; padding: 6px 12px;}' +) +"""Red-tinted banner for error state.""" + +UPDATE_BANNER_MESSAGE_STYLE = 'color: #d4d4d4; font-size: 12px;' +"""Style for the banner message text.""" + +UPDATE_BANNER_VERSION_STYLE = 'color: #d4d4d4; font-size: 12px; font-weight: bold;' +"""Style for the version number in the banner.""" + +UPDATE_BANNER_BTN_STYLE = ( + 'QPushButton {' + ' background: #0e639c;' + ' color: white;' + ' border: none;' + ' border-radius: 3px;' + ' padding: 4px 12px;' + ' font-size: 11px;' + ' font-weight: bold;' + '}' + 'QPushButton:hover { background: #1177bb; }' + 'QPushButton:pressed { background: #0d5689; }' +) +"""Primary action button style (Restart Now, Retry).""" + +UPDATE_BANNER_DISMISS_STYLE = ( + 'QPushButton {' + ' color: #808080;' + ' border: none;' + ' font-size: 14px;' + ' padding: 2px 6px;' + '}' + 'QPushButton:hover { color: #d4d4d4; }' +) +"""Dismiss (×) button style.""" + +UPDATE_BANNER_PROGRESS_STYLE = ( + 'QProgressBar {' + ' background: #2a2d2e;' + ' border: none;' + ' border-radius: 2px;' + ' max-height: 3px;' + '}' + 'QProgressBar::chunk {' + ' background: #0e639c;' + ' border-radius: 2px;' + '}' +) +"""Thin inline progress bar for the downloading state.""" diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index 39c105d..7e5a80a 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -369,3 +369,11 @@ def test_reset_check_updates_button() -> None: window.reset_check_updates_button() assert window._check_updates_btn.isEnabled() is True + + @staticmethod + def test_set_checking() -> None: + """set_checking disables the button and shows 'Checking\u2026' status.""" + window = _make_window() + window.set_checking() + assert window._check_updates_btn.isEnabled() is False + assert window._update_status_label.text() == 'Checking\u2026' diff --git a/tests/unit/qt/test_update_banner.py b/tests/unit/qt/test_update_banner.py new file mode 100644 index 0000000..2f1d16c --- /dev/null +++ b/tests/unit/qt/test_update_banner.py @@ -0,0 +1,185 @@ +"""Tests for the UpdateBanner widget.""" + +from __future__ import annotations + +import sys + +from PySide6.QtWidgets import QApplication + +from synodic_client.application.screen.update_banner import UpdateBanner, UpdateBannerState + +_app = QApplication.instance() or QApplication(sys.argv) + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestUpdateBannerConstruction: + """Basic construction and default state.""" + + def test_starts_hidden(self) -> None: + banner = UpdateBanner() + assert banner.state == UpdateBannerState.HIDDEN + assert banner.maximumHeight() == 0 + assert not banner.isVisible() + + def test_progress_bar_hidden_initially(self) -> None: + banner = UpdateBanner() + assert not banner._progress.isVisible() + + def test_action_btn_hidden_initially(self) -> None: + banner = UpdateBanner() + assert not banner._action_btn.isVisible() + + +# --------------------------------------------------------------------------- +# State transitions +# --------------------------------------------------------------------------- + + +class TestUpdateBannerStateTransitions: + """Verify visual state transitions.""" + + def test_show_downloading(self) -> None: + banner = UpdateBanner() + banner.show_downloading('1.2.3') + assert banner.state == UpdateBannerState.DOWNLOADING + assert banner._target_version == '1.2.3' + assert banner._progress.isVisible() + assert not banner._action_btn.isVisible() + assert '1.2.3' in banner._message.text() + + def test_show_downloading_progress(self) -> None: + banner = UpdateBanner() + banner.show_downloading('1.0.0') + # First progress value switches from indeterminate to determinate + banner.show_downloading_progress(42) + assert banner._progress.maximum() == 100 + assert banner._progress.value() == 42 + + def test_show_downloading_progress_ignored_when_not_downloading(self) -> None: + banner = UpdateBanner() + banner.show_ready('1.0.0') + # Should be a no-op, not crash + banner.show_downloading_progress(50) + assert banner.state == UpdateBannerState.READY + + def test_show_ready(self) -> None: + banner = UpdateBanner() + banner.show_ready('2.0.0') + assert banner.state == UpdateBannerState.READY + assert banner._action_btn.isVisible() + assert banner._action_btn.text() == 'Restart Now' + assert not banner._progress.isVisible() + assert '2.0.0' in banner._message.text() + + def test_show_error(self) -> None: + banner = UpdateBanner() + banner.show_error('Something broke') + assert banner.state == UpdateBannerState.ERROR + assert banner._action_btn.isVisible() + assert banner._action_btn.text() == 'Retry' + assert 'Something broke' in banner._message.text() + + def test_hide_banner(self) -> None: + banner = UpdateBanner() + banner.show_ready('1.0.0') + assert banner.state == UpdateBannerState.READY + banner.hide_banner() + assert banner.state == UpdateBannerState.HIDDEN + + def test_hide_banner_noop_when_already_hidden(self) -> None: + banner = UpdateBanner() + banner.hide_banner() # should not raise + assert banner.state == UpdateBannerState.HIDDEN + + def test_downloading_to_ready_transition(self) -> None: + banner = UpdateBanner() + banner.show_downloading('3.0.0') + assert banner.state == UpdateBannerState.DOWNLOADING + banner.show_ready('3.0.0') + assert banner.state == UpdateBannerState.READY + assert not banner._progress.isVisible() + + def test_error_to_downloading_transition(self) -> None: + banner = UpdateBanner() + banner.show_error('fail') + assert banner.state == UpdateBannerState.ERROR + banner.show_downloading('4.0.0') + assert banner.state == UpdateBannerState.DOWNLOADING + + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + + +class TestUpdateBannerSignals: + """Verify signal emissions from user actions.""" + + def test_restart_signal_on_ready_action(self) -> None: + banner = UpdateBanner() + banner.show_ready('1.0.0') + + received = [] + banner.restart_requested.connect(lambda: received.append(True)) + banner._action_btn.click() + assert received == [True] + + def test_retry_signal_on_error_action(self) -> None: + banner = UpdateBanner() + banner.show_error('oops') + + received = [] + banner.retry_requested.connect(lambda: received.append(True)) + banner._action_btn.click() + assert received == [True] + + def test_dismissed_signal_on_dismiss(self) -> None: + banner = UpdateBanner() + banner.show_ready('1.0.0') + + received = [] + banner.dismissed.connect(lambda: received.append(True)) + banner._dismiss_btn.click() + assert received == [True] + assert banner.state == UpdateBannerState.HIDDEN + + def test_action_btn_click_when_hidden_is_noop(self) -> None: + """Clicking the action button when hidden should emit no signal.""" + banner = UpdateBanner() + + received: list[str] = [] + banner.restart_requested.connect(lambda: received.append('restart')) + banner.retry_requested.connect(lambda: received.append('retry')) + banner._action_btn.click() + assert received == [] + + +# --------------------------------------------------------------------------- +# Error auto-dismiss +# --------------------------------------------------------------------------- + + +class TestUpdateBannerAutoDismiss: + """Verify the error banner auto-dismiss timer.""" + + def test_error_auto_dismiss_resets_to_hidden(self) -> None: + """The error banner should auto-dismiss after the configured delay.""" + banner = UpdateBanner() + banner.show_error('transient error') + assert banner.state == UpdateBannerState.ERROR + + # Directly invoke the auto-dismiss slot instead of waiting + banner._auto_dismiss_error() + assert banner.state == UpdateBannerState.HIDDEN + + def test_auto_dismiss_noop_if_state_changed(self) -> None: + """If the state changed before the timer fires, it's a no-op.""" + banner = UpdateBanner() + banner.show_error('oops') + banner.show_ready('1.0.0') # state changed to READY + banner._auto_dismiss_error() # should not reset to HIDDEN + assert banner.state == UpdateBannerState.READY