synodic-client 0.0.1.dev35__tar.gz → 0.0.1.dev36__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/pyproject.toml +1 -1
  3. synodic_client-0.0.1.dev36/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/screen.py +17 -1
  5. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/settings.py +5 -0
  6. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/tray.py +40 -139
  7. synodic_client-0.0.1.dev36/synodic_client/application/screen/update_banner.py +305 -0
  8. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/theme.py +70 -0
  9. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_settings.py +8 -0
  10. synodic_client-0.0.1.dev36/tests/unit/qt/test_update_banner.py +185 -0
  11. synodic_client-0.0.1.dev35/synodic_client/_version.py +0 -1
  12. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/LICENSE.md +0 -0
  13. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/README.md +0 -0
  14. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/__init__.py +0 -0
  15. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/__main__.py +0 -0
  16. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/__init__.py +0 -0
  17. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/bootstrap.py +0 -0
  18. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/icon.py +0 -0
  19. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/instance.py +0 -0
  20. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/qt.py +0 -0
  21. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/__init__.py +0 -0
  22. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/action_card.py +0 -0
  23. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/card.py +0 -0
  24. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/install.py +0 -0
  25. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/log_panel.py +0 -0
  26. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/screen/spinner.py +0 -0
  27. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/uri.py +0 -0
  28. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/application/workers.py +0 -0
  29. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/cli.py +0 -0
  30. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/client.py +0 -0
  31. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/config.py +0 -0
  32. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/logging.py +0 -0
  33. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/protocol.py +0 -0
  34. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/py.typed +0 -0
  35. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/resolution.py +0 -0
  36. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/startup.py +0 -0
  37. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/synodic_client/updater.py +0 -0
  38. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/__init__.py +0 -0
  39. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/conftest.py +0 -0
  40. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/__init__.py +0 -0
  41. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/qt/__init__.py +0 -0
  42. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/qt/conftest.py +0 -0
  43. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_action_card.py +0 -0
  44. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_install_preview.py +0 -0
  45. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_log_panel.py +0 -0
  46. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/qt/test_logging.py +0 -0
  47. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/test_cli.py +0 -0
  48. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/test_client_updater.py +0 -0
  49. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/test_client_version.py +0 -0
  50. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/test_config.py +0 -0
  51. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/test_examples.py +0 -0
  52. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/test_install.py +0 -0
  53. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/test_resolution.py +0 -0
  54. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/test_updater.py +0 -0
  55. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/test_uri.py +0 -0
  56. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/windows/__init__.py +0 -0
  57. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/windows/conftest.py +0 -0
  58. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/windows/test_protocol.py +0 -0
  59. {synodic_client-0.0.1.dev35 → synodic_client-0.0.1.dev36}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev35
3
+ Version: 0.0.1.dev36
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1442.dev64255",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev35"
18
+ version = "0.0.1.dev36"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev36'
@@ -38,6 +38,7 @@ from synodic_client.application.screen.install import (
38
38
  normalize_manifest_key,
39
39
  )
40
40
  from synodic_client.application.screen.spinner import SpinnerWidget
41
+ from synodic_client.application.screen.update_banner import UpdateBanner
41
42
  from synodic_client.application.theme import (
42
43
  CARD_SPACING,
43
44
  COMPACT_MARGINS,
@@ -815,6 +816,9 @@ class MainWindow(QMainWindow):
815
816
  self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE)
816
817
  self.setWindowIcon(app_icon())
817
818
 
819
+ # Update banner — always available, starts hidden.
820
+ self._update_banner = UpdateBanner(self)
821
+
818
822
  @property
819
823
  def porringer(self) -> API | None:
820
824
  """Return the porringer API instance, if available."""
@@ -825,6 +829,11 @@ class MainWindow(QMainWindow):
825
829
  """Return the plugins view, if initialised."""
826
830
  return self._plugins_view
827
831
 
832
+ @property
833
+ def update_banner(self) -> UpdateBanner:
834
+ """Return the update banner widget."""
835
+ return self._update_banner
836
+
828
837
  def show(self) -> None:
829
838
  """Show the window, initializing UI lazily on first show."""
830
839
  if self._tabs is None and self._porringer is not None and self._config is not None:
@@ -843,7 +852,14 @@ class MainWindow(QMainWindow):
843
852
  gear_btn.clicked.connect(self.settings_requested.emit)
844
853
  self._tabs.setCornerWidget(gear_btn)
845
854
 
846
- self.setCentralWidget(self._tabs)
855
+ # Container: banner above tabs
856
+ container = QWidget(self)
857
+ container_layout = QVBoxLayout(container)
858
+ container_layout.setContentsMargins(0, 0, 0, 0)
859
+ container_layout.setSpacing(0)
860
+ container_layout.addWidget(self._update_banner)
861
+ container_layout.addWidget(self._tabs)
862
+ self.setCentralWidget(container)
847
863
 
848
864
  # Paint the window immediately, then refresh data asynchronously
849
865
  super().show()
@@ -224,6 +224,11 @@ class SettingsWindow(QMainWindow):
224
224
  """Set the inline status text next to the *Check for Updates* button."""
225
225
  self._update_status_label.setText(text)
226
226
 
227
+ def set_checking(self) -> None:
228
+ """Enter the *checking* state — disable button and show status."""
229
+ self._check_updates_btn.setEnabled(False)
230
+ self._update_status_label.setText('Checking\u2026')
231
+
227
232
  def reset_check_updates_button(self) -> None:
228
233
  """Re-enable the *Check for Updates* button after a check completes."""
229
234
  self._check_updates_btn.setEnabled(True)
@@ -10,8 +10,6 @@ from PySide6.QtGui import QAction
10
10
  from PySide6.QtWidgets import (
11
11
  QApplication,
12
12
  QMenu,
13
- QMessageBox,
14
- QProgressDialog,
15
13
  QSystemTrayIcon,
16
14
  )
17
15
 
@@ -56,15 +54,11 @@ class TrayScreen:
56
54
  self._config = config
57
55
  self._runner: QThread | None = None
58
56
  self._tool_runner: QThread | None = None
59
- self._progress_dialog: QProgressDialog | None = None
60
- self._pending_update_info: UpdateInfo | None = None
61
- self._download_cancelled = False
62
57
 
63
58
  self.tray_icon = app_icon()
64
59
 
65
60
  self.tray = QSystemTrayIcon()
66
61
  self.tray.setIcon(self.tray_icon)
67
- self.tray.messageClicked.connect(self._on_notification_clicked)
68
62
  self.tray.activated.connect(self._on_tray_activated)
69
63
  self.tray.setVisible(True)
70
64
 
@@ -92,6 +86,11 @@ class TrayScreen:
92
86
  plugins_view.update_all_requested.connect(self._on_tool_update)
93
87
  plugins_view.plugin_update_requested.connect(self._on_single_plugin_update)
94
88
 
89
+ # Connect update banner signals
90
+ self._banner = window.update_banner
91
+ self._banner.restart_requested.connect(self._apply_update)
92
+ self._banner.retry_requested.connect(lambda: self._do_check_updates(silent=True))
93
+
95
94
  def _build_menu(self, app: QApplication, window: MainWindow) -> None:
96
95
  """Build the tray context menu."""
97
96
  self.menu = QMenu()
@@ -216,12 +215,6 @@ class TrayScreen:
216
215
  self.update_action.setEnabled(True)
217
216
  self.update_action.setText('Check for Updates...')
218
217
 
219
- def _close_progress(self) -> None:
220
- """Close and discard the download progress dialog, if open."""
221
- if self._progress_dialog:
222
- self._progress_dialog.close()
223
- self._progress_dialog = None
224
-
225
218
  def _on_check_updates(self) -> None:
226
219
  """Handle manual check for updates action."""
227
220
  self._do_check_updates(silent=False)
@@ -230,7 +223,7 @@ class TrayScreen:
230
223
  """Handle automatic (periodic) check for updates.
231
224
 
232
225
  Failures and no-update results are logged silently without
233
- showing Windows notifications.
226
+ showing the in-app error banner.
234
227
  """
235
228
  self._do_check_updates(silent=True)
236
229
 
@@ -238,24 +231,19 @@ class TrayScreen:
238
231
  """Run an update check.
239
232
 
240
233
  Args:
241
- silent: When ``True``, suppress notifications for failures
242
- and no-update results. Notifications are still shown
243
- when an update *is* available.
234
+ silent: When ``True``, suppress the in-app error banner
235
+ for failures and no-update results. The banner is
236
+ always shown when an update *is* available.
244
237
  """
245
238
  if self._client.updater is None:
246
239
  if not silent:
247
- self.tray.showMessage(
248
- 'Update Error',
249
- 'Updater is not initialized.',
250
- QSystemTrayIcon.MessageIcon.Warning,
251
- )
240
+ self._banner.show_error('Updater is not initialized.')
252
241
  return
253
242
 
254
243
  # Disable both the tray action and the settings button while checking
255
244
  self.update_action.setEnabled(False)
256
245
  self.update_action.setText('Checking for Updates...')
257
- self._settings_window._check_updates_btn.setEnabled(False)
258
- self._settings_window.set_update_status('Checking\u2026')
246
+ self._settings_window.set_checking()
259
247
 
260
248
  worker = UpdateCheckWorker(self._client)
261
249
  worker.finished.connect(lambda result: self._on_update_check_finished(result, silent=silent))
@@ -272,11 +260,7 @@ class TrayScreen:
272
260
  if result is None:
273
261
  self._settings_window.set_update_status('Check failed')
274
262
  if not silent:
275
- self.tray.showMessage(
276
- 'Update Check Failed',
277
- 'Failed to check for updates. Please try again later.',
278
- QSystemTrayIcon.MessageIcon.Warning,
279
- )
263
+ self._banner.show_error('Failed to check for updates.')
280
264
  else:
281
265
  logger.warning('Automatic update check failed (no result)')
282
266
  return
@@ -284,14 +268,7 @@ class TrayScreen:
284
268
  if result.error:
285
269
  self._settings_window.set_update_status(result.error)
286
270
  if not silent:
287
- # Distinguish informational messages (no releases for channel)
288
- # from genuine failures.
289
- is_no_releases = 'No releases found' in result.error
290
- title = 'No Updates Available' if is_no_releases else 'Update Check Failed'
291
- icon = (
292
- QSystemTrayIcon.MessageIcon.Information if is_no_releases else QSystemTrayIcon.MessageIcon.Warning
293
- )
294
- self.tray.showMessage(title, result.error, icon)
271
+ self._banner.show_error(result.error)
295
272
  else:
296
273
  logger.warning('Automatic update check failed: %s', result.error)
297
274
  return
@@ -301,25 +278,16 @@ class TrayScreen:
301
278
  f'Up to date ({result.current_version})',
302
279
  )
303
280
  if not silent:
304
- self.tray.showMessage(
305
- 'No Updates Available',
306
- f'You are running the latest version ({result.current_version}).',
307
- QSystemTrayIcon.MessageIcon.Information,
308
- )
281
+ logger.info('No updates available (current: %s)', result.current_version)
309
282
  else:
310
283
  logger.debug('Automatic update check: no update available')
311
284
  return
312
285
 
313
- # Update available - always show notification, clicking it starts download
314
- self._pending_update_info = result
315
- self._settings_window.set_update_status(
316
- f'Update available: {result.latest_version}',
317
- )
318
- self.tray.showMessage(
319
- 'Update Available',
320
- f'Version {result.latest_version} is available (current: {result.current_version}).\nClick to download.',
321
- QSystemTrayIcon.MessageIcon.Information,
322
- )
286
+ # Update available show banner and start download automatically
287
+ version = str(result.latest_version)
288
+ self._settings_window.set_update_status(f'Update available: {version}')
289
+ self._banner.show_downloading(version)
290
+ self._start_download(version)
323
291
 
324
292
  def _on_update_check_error(self, error: str, *, silent: bool = False) -> None:
325
293
  """Handle update check error."""
@@ -328,11 +296,7 @@ class TrayScreen:
328
296
  self._settings_window.set_update_status(f'Error: {error}')
329
297
 
330
298
  if not silent:
331
- self.tray.showMessage(
332
- 'Update Check Error',
333
- f'An error occurred: {error}',
334
- QSystemTrayIcon.MessageIcon.Critical,
335
- )
299
+ self._banner.show_error(f'Update check error: {error}')
336
300
  else:
337
301
  logger.warning('Automatic update check error: %s', error)
338
302
 
@@ -393,107 +357,44 @@ class TrayScreen:
393
357
  QSystemTrayIcon.MessageIcon.Warning,
394
358
  )
395
359
 
396
- def _on_notification_clicked(self) -> None:
397
- """Handle notification click - starts download if update is pending."""
398
- if self._pending_update_info is not None and self._pending_update_info.available:
399
- self._pending_update_info = None
400
- self._start_download()
401
-
402
- def _start_download(self) -> None:
403
- """Start downloading the update."""
404
- # Create progress dialog
405
- self._progress_dialog = QProgressDialog(
406
- 'Downloading update...',
407
- 'Cancel',
408
- 0,
409
- 100,
410
- self._window,
411
- )
412
- self._progress_dialog.setWindowTitle('Downloading Update')
413
- self._progress_dialog.setAutoClose(False)
414
- self._progress_dialog.setAutoReset(False)
415
- self._progress_dialog.canceled.connect(self._on_download_cancelled)
416
- self._download_cancelled = False
417
- self._progress_dialog.show()
360
+ # -- Self-update download & apply --
418
361
 
362
+ def _start_download(self, version: str) -> None:
363
+ """Start downloading the update in the background.
364
+
365
+ Args:
366
+ version: The version string being downloaded (for banner display).
367
+ """
419
368
  worker = UpdateDownloadWorker(self._client)
420
- worker.finished.connect(self._on_download_finished)
421
- worker.progress.connect(self._on_download_progress)
369
+ worker.finished.connect(lambda success: self._on_download_finished(success, version))
370
+ worker.progress.connect(self._banner.show_downloading_progress)
422
371
  worker.error.connect(self._on_download_error)
423
372
 
424
373
  self._runner = worker
425
374
  self._runner.start()
426
375
 
427
- def _on_download_cancelled(self) -> None:
428
- """Handle cancel button on the download progress dialog."""
429
- self._download_cancelled = True
430
- self._close_progress()
431
- logger.info('Update download cancelled by user')
432
-
433
- def _on_download_progress(self, percentage: int) -> None:
434
- """Handle download progress update."""
435
- if self._progress_dialog and not self._download_cancelled:
436
- self._progress_dialog.setValue(percentage)
437
- self._progress_dialog.setLabelText(f'Downloading update... {percentage}%')
438
-
439
- def _on_download_finished(self, success: bool) -> None:
440
- """Handle download completion."""
441
- self._close_progress()
442
-
443
- if self._download_cancelled:
444
- return
445
-
376
+ def _on_download_finished(self, success: bool, version: str) -> None:
377
+ """Handle download completion transition banner to ready state."""
446
378
  if not success:
447
- self.tray.showMessage(
448
- 'Download Failed',
449
- 'Failed to download the update. Please try again later.',
450
- QSystemTrayIcon.MessageIcon.Warning,
451
- )
379
+ self._banner.show_error('Download failed. Please try again later.')
452
380
  return
453
381
 
454
- # Prompt to apply update - keep as dialog since it needs user choice
455
- reply = QMessageBox.question(
456
- self._window if self._window.isVisible() else None,
457
- 'Download Complete',
458
- 'The update has been downloaded.\n\n'
459
- 'Would you like to install it now?\n'
460
- 'The application will restart after installation.',
461
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
462
- QMessageBox.StandardButton.Yes,
463
- )
464
-
465
- if reply == QMessageBox.StandardButton.Yes:
466
- self._apply_update()
382
+ self._banner.show_ready(version)
383
+ self._settings_window.set_update_status(f'Ready to install: {version}')
467
384
 
468
385
  def _on_download_error(self, error: str) -> None:
469
- """Handle download error."""
470
- self._close_progress()
471
-
472
- self.tray.showMessage(
473
- 'Download Error',
474
- f'An error occurred while downloading: {error}',
475
- QSystemTrayIcon.MessageIcon.Critical,
476
- )
386
+ """Handle download error — show error banner."""
387
+ self._banner.show_error(f'Download error: {error}')
477
388
 
478
389
  def _apply_update(self) -> None:
479
- """Apply the downloaded update."""
390
+ """Apply the downloaded update and restart."""
480
391
  if self._client.updater is None:
481
392
  return
482
393
 
483
394
  try:
484
- # Schedule update to apply on exit, then quit the app
485
395
  self._client.apply_update_on_exit(restart=True)
486
-
487
- self.tray.showMessage(
488
- 'Update Ready',
489
- 'The update will be applied when the application closes.\nThe application will restart automatically.',
490
- QSystemTrayIcon.MessageIcon.Information,
491
- )
396
+ logger.info('Update scheduled — restarting application')
492
397
  self._app.quit()
493
-
494
398
  except Exception as e:
495
- self.tray.showMessage(
496
- 'Update Failed',
497
- f'Failed to apply the update: {e}',
498
- QSystemTrayIcon.MessageIcon.Warning,
499
- )
399
+ logger.error('Failed to apply update: %s', e)
400
+ self._banner.show_error(f'Failed to apply update: {e}')
@@ -0,0 +1,305 @@
1
+ """In-app update banner for the self-update lifecycle.
2
+
3
+ Replaces Windows tray balloon notifications and modal dialogs with a
4
+ persistent, non-intrusive banner displayed at the top of the main
5
+ window. The banner transitions through three visual states:
6
+
7
+ * **downloading** — update detected, auto-downloading in the background.
8
+ * **ready** — download complete; user can restart at their convenience.
9
+ * **error** — check or download failed with a retry option.
10
+
11
+ The banner slides in/out using a ``QPropertyAnimation`` on
12
+ ``maximumHeight`` for a polished feel.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from enum import Enum, auto
19
+
20
+ from PySide6.QtCore import (
21
+ QEasingCurve,
22
+ QPropertyAnimation,
23
+ Qt,
24
+ QTimer,
25
+ Signal,
26
+ )
27
+ from PySide6.QtWidgets import (
28
+ QFrame,
29
+ QHBoxLayout,
30
+ QLabel,
31
+ QProgressBar,
32
+ QPushButton,
33
+ QSizePolicy,
34
+ QVBoxLayout,
35
+ QWidget,
36
+ )
37
+
38
+ from synodic_client.application.theme import (
39
+ UPDATE_BANNER_ANIMATION_MS,
40
+ UPDATE_BANNER_BTN_STYLE,
41
+ UPDATE_BANNER_DISMISS_STYLE,
42
+ UPDATE_BANNER_ERROR_DISMISS_MS,
43
+ UPDATE_BANNER_ERROR_STYLE,
44
+ UPDATE_BANNER_MESSAGE_STYLE,
45
+ UPDATE_BANNER_PROGRESS_STYLE,
46
+ UPDATE_BANNER_READY_STYLE,
47
+ UPDATE_BANNER_STYLE,
48
+ UPDATE_BANNER_VERSION_STYLE,
49
+ )
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ class UpdateBannerState(Enum):
55
+ """Visual states for the update banner."""
56
+
57
+ HIDDEN = auto()
58
+ DOWNLOADING = auto()
59
+ READY = auto()
60
+ ERROR = auto()
61
+
62
+
63
+ # Height of the banner content (progress variant is slightly taller).
64
+ _BANNER_HEIGHT = 38
65
+ _BANNER_HEIGHT_WITH_PROGRESS = 44
66
+
67
+
68
+ class UpdateBanner(QFrame):
69
+ """Non-intrusive in-app banner for the self-update lifecycle.
70
+
71
+ Signals:
72
+ restart_requested: User clicked "Restart Now".
73
+ retry_requested: User clicked "Retry" on an error banner.
74
+ dismissed: User clicked the dismiss (×) button.
75
+ """
76
+
77
+ restart_requested = Signal()
78
+ retry_requested = Signal()
79
+ dismissed = Signal()
80
+
81
+ def __init__(self, parent: QWidget | None = None) -> None:
82
+ super().__init__(parent)
83
+ self.setObjectName('updateBanner')
84
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
85
+
86
+ self._state = UpdateBannerState.HIDDEN
87
+ self._target_version: str = ''
88
+
89
+ # --- Layout ---
90
+ self._outer = QVBoxLayout(self)
91
+ self._outer.setContentsMargins(0, 0, 0, 0)
92
+ self._outer.setSpacing(0)
93
+
94
+ # Row: icon · message · [progress_label] · [action_btn] · dismiss
95
+ self._row = QHBoxLayout()
96
+ self._row.setContentsMargins(12, 6, 12, 4)
97
+ self._row.setSpacing(8)
98
+ self._outer.addLayout(self._row)
99
+
100
+ self._icon_label = QLabel('\U0001f504') # 🔄
101
+ self._icon_label.setFixedWidth(18)
102
+ self._row.addWidget(self._icon_label)
103
+
104
+ self._message = QLabel()
105
+ self._message.setStyleSheet(UPDATE_BANNER_MESSAGE_STYLE)
106
+ self._row.addWidget(self._message)
107
+
108
+ self._row.addStretch()
109
+
110
+ self._action_btn = QPushButton()
111
+ self._action_btn.setStyleSheet(UPDATE_BANNER_BTN_STYLE)
112
+ self._action_btn.clicked.connect(self._on_action)
113
+ self._action_btn.hide()
114
+ self._row.addWidget(self._action_btn)
115
+
116
+ self._dismiss_btn = QPushButton('\u00d7') # ×
117
+ self._dismiss_btn.setStyleSheet(UPDATE_BANNER_DISMISS_STYLE)
118
+ self._dismiss_btn.setFixedWidth(24)
119
+ self._dismiss_btn.clicked.connect(self._on_dismiss)
120
+ self._row.addWidget(self._dismiss_btn)
121
+
122
+ # Thin progress bar (only visible during download)
123
+ self._progress = QProgressBar()
124
+ self._progress.setStyleSheet(UPDATE_BANNER_PROGRESS_STYLE)
125
+ self._progress.setTextVisible(False)
126
+ self._progress.setRange(0, 0) # indeterminate
127
+ self._progress.setFixedHeight(3)
128
+ self._progress.hide()
129
+ self._outer.addWidget(self._progress)
130
+
131
+ # Start fully collapsed
132
+ self.setMaximumHeight(0)
133
+ self.setVisible(False)
134
+
135
+ # Animation for slide-in / slide-out
136
+ self._anim = QPropertyAnimation(self, b'maximumHeight')
137
+ self._anim.setEasingCurve(QEasingCurve.Type.OutCubic)
138
+ self._anim.setDuration(UPDATE_BANNER_ANIMATION_MS)
139
+
140
+ # --- Public API ---
141
+
142
+ @property
143
+ def state(self) -> UpdateBannerState:
144
+ """Current visual state of the banner."""
145
+ return self._state
146
+
147
+ def show_downloading(self, version: str) -> None:
148
+ """Transition to the *downloading* state.
149
+
150
+ Args:
151
+ version: The version string being downloaded (e.g. ``"0.0.1.dev35"``).
152
+ """
153
+ self._configure(
154
+ state=UpdateBannerState.DOWNLOADING,
155
+ version=version,
156
+ style=UPDATE_BANNER_STYLE,
157
+ icon='\u2b07',
158
+ text=f'Downloading update <b>{version}</b>\u2026',
159
+ text_style=UPDATE_BANNER_MESSAGE_STYLE,
160
+ show_progress=True,
161
+ )
162
+
163
+ def show_downloading_progress(self, percentage: int) -> None:
164
+ """Update the progress bar during download.
165
+
166
+ Args:
167
+ percentage: Download progress 0–100.
168
+ """
169
+ if self._state != UpdateBannerState.DOWNLOADING:
170
+ return
171
+ if self._progress.maximum() == 0:
172
+ # Switch from indeterminate to determinate on first real value
173
+ self._progress.setRange(0, 100)
174
+ self._progress.setValue(percentage)
175
+
176
+ def show_ready(self, version: str) -> None:
177
+ """Transition to the *ready* state.
178
+
179
+ Args:
180
+ version: The version that is ready to install.
181
+ """
182
+ self._configure(
183
+ state=UpdateBannerState.READY,
184
+ version=version,
185
+ style=UPDATE_BANNER_READY_STYLE,
186
+ icon='\u2705',
187
+ text=f'Update <b>{version}</b> is ready \u2014 restart to finish installing',
188
+ text_style=UPDATE_BANNER_VERSION_STYLE,
189
+ action_label='Restart Now',
190
+ )
191
+
192
+ def show_error(self, message: str) -> None:
193
+ """Transition to the *error* state.
194
+
195
+ Args:
196
+ message: Human-readable error description.
197
+ """
198
+ self._configure(
199
+ state=UpdateBannerState.ERROR,
200
+ style=UPDATE_BANNER_ERROR_STYLE,
201
+ icon='\u26a0',
202
+ text=message,
203
+ text_style=UPDATE_BANNER_MESSAGE_STYLE,
204
+ action_label='Retry',
205
+ )
206
+ QTimer.singleShot(UPDATE_BANNER_ERROR_DISMISS_MS, self._auto_dismiss_error)
207
+
208
+ def hide_banner(self) -> None:
209
+ """Slide the banner out and reset to hidden."""
210
+ if self._state == UpdateBannerState.HIDDEN:
211
+ return
212
+ self._state = UpdateBannerState.HIDDEN
213
+ self._slide_out()
214
+
215
+ # --- Internal ---
216
+
217
+ def _configure(
218
+ self,
219
+ *,
220
+ state: UpdateBannerState,
221
+ style: str,
222
+ icon: str,
223
+ text: str,
224
+ text_style: str,
225
+ version: str = '',
226
+ action_label: str = '',
227
+ show_progress: bool = False,
228
+ ) -> None:
229
+ """Apply common visual configuration and slide the banner in.
230
+
231
+ Args:
232
+ state: The new banner state.
233
+ style: QSS for the banner frame.
234
+ icon: Single character displayed as the leading icon.
235
+ text: Message (may contain HTML).
236
+ text_style: QSS for the message label.
237
+ version: Version string to store (optional).
238
+ action_label: Text for the action button; hidden when empty.
239
+ show_progress: Whether to show the progress bar.
240
+ """
241
+ self._state = state
242
+ self._target_version = version
243
+
244
+ self.setStyleSheet(style)
245
+ self._icon_label.setText(icon)
246
+ self._message.setText(text)
247
+ self._message.setStyleSheet(text_style)
248
+
249
+ if action_label:
250
+ self._action_btn.setText(action_label)
251
+ self._action_btn.show()
252
+ else:
253
+ self._action_btn.hide()
254
+
255
+ if show_progress:
256
+ self._progress.setRange(0, 0) # indeterminate
257
+ self._progress.show()
258
+ else:
259
+ self._progress.hide()
260
+
261
+ target_height = _BANNER_HEIGHT_WITH_PROGRESS if show_progress else _BANNER_HEIGHT
262
+ self._slide_in(target_height)
263
+
264
+ def _slide_in(self, target_height: int) -> None:
265
+ """Animate the banner from collapsed to *target_height*."""
266
+ self.setVisible(True)
267
+ self._anim.stop()
268
+ self._anim.setStartValue(self.maximumHeight())
269
+ self._anim.setEndValue(target_height)
270
+ self._anim.start()
271
+
272
+ def _slide_out(self) -> None:
273
+ """Animate the banner down to zero height, then hide."""
274
+ self._anim.stop()
275
+ self._anim.setStartValue(self.maximumHeight())
276
+ self._anim.setEndValue(0)
277
+ # Use a one-shot connection to avoid accumulating slots.
278
+ self._anim.finished.connect(
279
+ self._on_slide_out_done,
280
+ type=Qt.ConnectionType.SingleShotConnection,
281
+ )
282
+ self._anim.start()
283
+
284
+ def _on_slide_out_done(self) -> None:
285
+ """Hide the widget once the slide-out animation completes."""
286
+ if self._state == UpdateBannerState.HIDDEN:
287
+ self.setVisible(False)
288
+
289
+ def _on_action(self) -> None:
290
+ """Handle the primary action button click."""
291
+ if self._state == UpdateBannerState.READY:
292
+ self.restart_requested.emit()
293
+ elif self._state == UpdateBannerState.ERROR:
294
+ self.hide_banner()
295
+ self.retry_requested.emit()
296
+
297
+ def _on_dismiss(self) -> None:
298
+ """Handle the dismiss (×) button click."""
299
+ self.hide_banner()
300
+ self.dismissed.emit()
301
+
302
+ def _auto_dismiss_error(self) -> None:
303
+ """Auto-dismiss the error banner if it's still showing."""
304
+ if self._state == UpdateBannerState.ERROR:
305
+ self.hide_banner()
@@ -286,3 +286,73 @@ SETTINGS_GEAR_STYLE = (
286
286
  'QPushButton:hover { background: palette(midlight); border-radius: 3px; }'
287
287
  )
288
288
  """Gear button style for the MainWindow tab corner widget."""
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Update banner (in-app self-update notification)
292
+ # ---------------------------------------------------------------------------
293
+ UPDATE_BANNER_ANIMATION_MS = 250
294
+ """Duration of the slide-in / slide-out animation (ms)."""
295
+
296
+ UPDATE_BANNER_ERROR_DISMISS_MS = 10000
297
+ """Auto-dismiss delay for the error banner (ms)."""
298
+
299
+ UPDATE_BANNER_STYLE = (
300
+ 'QFrame#updateBanner { background: #1e3a5f; border-bottom: 1px solid #2a5a8f; padding: 6px 12px;}'
301
+ )
302
+ """Default banner style — subtle blue tint for downloading state."""
303
+
304
+ UPDATE_BANNER_READY_STYLE = (
305
+ 'QFrame#updateBanner { background: #1e3f2e; border-bottom: 1px solid #2a6f3f; padding: 6px 12px;}'
306
+ )
307
+ """Green-tinted banner for "ready to restart" state."""
308
+
309
+ UPDATE_BANNER_ERROR_STYLE = (
310
+ 'QFrame#updateBanner { background: #3f1e1e; border-bottom: 1px solid #6f2a2a; padding: 6px 12px;}'
311
+ )
312
+ """Red-tinted banner for error state."""
313
+
314
+ UPDATE_BANNER_MESSAGE_STYLE = 'color: #d4d4d4; font-size: 12px;'
315
+ """Style for the banner message text."""
316
+
317
+ UPDATE_BANNER_VERSION_STYLE = 'color: #d4d4d4; font-size: 12px; font-weight: bold;'
318
+ """Style for the version number in the banner."""
319
+
320
+ UPDATE_BANNER_BTN_STYLE = (
321
+ 'QPushButton {'
322
+ ' background: #0e639c;'
323
+ ' color: white;'
324
+ ' border: none;'
325
+ ' border-radius: 3px;'
326
+ ' padding: 4px 12px;'
327
+ ' font-size: 11px;'
328
+ ' font-weight: bold;'
329
+ '}'
330
+ 'QPushButton:hover { background: #1177bb; }'
331
+ 'QPushButton:pressed { background: #0d5689; }'
332
+ )
333
+ """Primary action button style (Restart Now, Retry)."""
334
+
335
+ UPDATE_BANNER_DISMISS_STYLE = (
336
+ 'QPushButton {'
337
+ ' color: #808080;'
338
+ ' border: none;'
339
+ ' font-size: 14px;'
340
+ ' padding: 2px 6px;'
341
+ '}'
342
+ 'QPushButton:hover { color: #d4d4d4; }'
343
+ )
344
+ """Dismiss (×) button style."""
345
+
346
+ UPDATE_BANNER_PROGRESS_STYLE = (
347
+ 'QProgressBar {'
348
+ ' background: #2a2d2e;'
349
+ ' border: none;'
350
+ ' border-radius: 2px;'
351
+ ' max-height: 3px;'
352
+ '}'
353
+ 'QProgressBar::chunk {'
354
+ ' background: #0e639c;'
355
+ ' border-radius: 2px;'
356
+ '}'
357
+ )
358
+ """Thin inline progress bar for the downloading state."""
@@ -369,3 +369,11 @@ class TestCheckForUpdatesButton:
369
369
  window.reset_check_updates_button()
370
370
 
371
371
  assert window._check_updates_btn.isEnabled() is True
372
+
373
+ @staticmethod
374
+ def test_set_checking() -> None:
375
+ """set_checking disables the button and shows 'Checking\u2026' status."""
376
+ window = _make_window()
377
+ window.set_checking()
378
+ assert window._check_updates_btn.isEnabled() is False
379
+ assert window._update_status_label.text() == 'Checking\u2026'
@@ -0,0 +1,185 @@
1
+ """Tests for the UpdateBanner widget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from PySide6.QtWidgets import QApplication
8
+
9
+ from synodic_client.application.screen.update_banner import UpdateBanner, UpdateBannerState
10
+
11
+ _app = QApplication.instance() or QApplication(sys.argv)
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Construction
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ class TestUpdateBannerConstruction:
20
+ """Basic construction and default state."""
21
+
22
+ def test_starts_hidden(self) -> None:
23
+ banner = UpdateBanner()
24
+ assert banner.state == UpdateBannerState.HIDDEN
25
+ assert banner.maximumHeight() == 0
26
+ assert not banner.isVisible()
27
+
28
+ def test_progress_bar_hidden_initially(self) -> None:
29
+ banner = UpdateBanner()
30
+ assert not banner._progress.isVisible()
31
+
32
+ def test_action_btn_hidden_initially(self) -> None:
33
+ banner = UpdateBanner()
34
+ assert not banner._action_btn.isVisible()
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # State transitions
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ class TestUpdateBannerStateTransitions:
43
+ """Verify visual state transitions."""
44
+
45
+ def test_show_downloading(self) -> None:
46
+ banner = UpdateBanner()
47
+ banner.show_downloading('1.2.3')
48
+ assert banner.state == UpdateBannerState.DOWNLOADING
49
+ assert banner._target_version == '1.2.3'
50
+ assert banner._progress.isVisible()
51
+ assert not banner._action_btn.isVisible()
52
+ assert '1.2.3' in banner._message.text()
53
+
54
+ def test_show_downloading_progress(self) -> None:
55
+ banner = UpdateBanner()
56
+ banner.show_downloading('1.0.0')
57
+ # First progress value switches from indeterminate to determinate
58
+ banner.show_downloading_progress(42)
59
+ assert banner._progress.maximum() == 100
60
+ assert banner._progress.value() == 42
61
+
62
+ def test_show_downloading_progress_ignored_when_not_downloading(self) -> None:
63
+ banner = UpdateBanner()
64
+ banner.show_ready('1.0.0')
65
+ # Should be a no-op, not crash
66
+ banner.show_downloading_progress(50)
67
+ assert banner.state == UpdateBannerState.READY
68
+
69
+ def test_show_ready(self) -> None:
70
+ banner = UpdateBanner()
71
+ banner.show_ready('2.0.0')
72
+ assert banner.state == UpdateBannerState.READY
73
+ assert banner._action_btn.isVisible()
74
+ assert banner._action_btn.text() == 'Restart Now'
75
+ assert not banner._progress.isVisible()
76
+ assert '2.0.0' in banner._message.text()
77
+
78
+ def test_show_error(self) -> None:
79
+ banner = UpdateBanner()
80
+ banner.show_error('Something broke')
81
+ assert banner.state == UpdateBannerState.ERROR
82
+ assert banner._action_btn.isVisible()
83
+ assert banner._action_btn.text() == 'Retry'
84
+ assert 'Something broke' in banner._message.text()
85
+
86
+ def test_hide_banner(self) -> None:
87
+ banner = UpdateBanner()
88
+ banner.show_ready('1.0.0')
89
+ assert banner.state == UpdateBannerState.READY
90
+ banner.hide_banner()
91
+ assert banner.state == UpdateBannerState.HIDDEN
92
+
93
+ def test_hide_banner_noop_when_already_hidden(self) -> None:
94
+ banner = UpdateBanner()
95
+ banner.hide_banner() # should not raise
96
+ assert banner.state == UpdateBannerState.HIDDEN
97
+
98
+ def test_downloading_to_ready_transition(self) -> None:
99
+ banner = UpdateBanner()
100
+ banner.show_downloading('3.0.0')
101
+ assert banner.state == UpdateBannerState.DOWNLOADING
102
+ banner.show_ready('3.0.0')
103
+ assert banner.state == UpdateBannerState.READY
104
+ assert not banner._progress.isVisible()
105
+
106
+ def test_error_to_downloading_transition(self) -> None:
107
+ banner = UpdateBanner()
108
+ banner.show_error('fail')
109
+ assert banner.state == UpdateBannerState.ERROR
110
+ banner.show_downloading('4.0.0')
111
+ assert banner.state == UpdateBannerState.DOWNLOADING
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Signals
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ class TestUpdateBannerSignals:
120
+ """Verify signal emissions from user actions."""
121
+
122
+ def test_restart_signal_on_ready_action(self) -> None:
123
+ banner = UpdateBanner()
124
+ banner.show_ready('1.0.0')
125
+
126
+ received = []
127
+ banner.restart_requested.connect(lambda: received.append(True))
128
+ banner._action_btn.click()
129
+ assert received == [True]
130
+
131
+ def test_retry_signal_on_error_action(self) -> None:
132
+ banner = UpdateBanner()
133
+ banner.show_error('oops')
134
+
135
+ received = []
136
+ banner.retry_requested.connect(lambda: received.append(True))
137
+ banner._action_btn.click()
138
+ assert received == [True]
139
+
140
+ def test_dismissed_signal_on_dismiss(self) -> None:
141
+ banner = UpdateBanner()
142
+ banner.show_ready('1.0.0')
143
+
144
+ received = []
145
+ banner.dismissed.connect(lambda: received.append(True))
146
+ banner._dismiss_btn.click()
147
+ assert received == [True]
148
+ assert banner.state == UpdateBannerState.HIDDEN
149
+
150
+ def test_action_btn_click_when_hidden_is_noop(self) -> None:
151
+ """Clicking the action button when hidden should emit no signal."""
152
+ banner = UpdateBanner()
153
+
154
+ received: list[str] = []
155
+ banner.restart_requested.connect(lambda: received.append('restart'))
156
+ banner.retry_requested.connect(lambda: received.append('retry'))
157
+ banner._action_btn.click()
158
+ assert received == []
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Error auto-dismiss
163
+ # ---------------------------------------------------------------------------
164
+
165
+
166
+ class TestUpdateBannerAutoDismiss:
167
+ """Verify the error banner auto-dismiss timer."""
168
+
169
+ def test_error_auto_dismiss_resets_to_hidden(self) -> None:
170
+ """The error banner should auto-dismiss after the configured delay."""
171
+ banner = UpdateBanner()
172
+ banner.show_error('transient error')
173
+ assert banner.state == UpdateBannerState.ERROR
174
+
175
+ # Directly invoke the auto-dismiss slot instead of waiting
176
+ banner._auto_dismiss_error()
177
+ assert banner.state == UpdateBannerState.HIDDEN
178
+
179
+ def test_auto_dismiss_noop_if_state_changed(self) -> None:
180
+ """If the state changed before the timer fires, it's a no-op."""
181
+ banner = UpdateBanner()
182
+ banner.show_error('oops')
183
+ banner.show_ready('1.0.0') # state changed to READY
184
+ banner._auto_dismiss_error() # should not reset to HIDDEN
185
+ assert banner.state == UpdateBannerState.READY
@@ -1 +0,0 @@
1
- __version__ = '0.0.1.dev35'