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