synodic-client 0.0.1.dev80__tar.gz → 0.0.1.dev82__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 (106) hide show
  1. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/PKG-INFO +4 -4
  2. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/pyproject.toml +5 -5
  3. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/action_card.py +92 -15
  4. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/screen.py +20 -0
  5. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/tray.py +21 -4
  6. synodic_client-0.0.1.dev82/synodic_client/application/screen/wsl.py +419 -0
  7. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/theme.py +16 -0
  8. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/operations/install.py +1 -0
  9. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/operations/schema.py +2 -0
  10. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/updater.py +3 -4
  11. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_tray_window_show.py +30 -0
  12. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/LICENSE.md +0 -0
  13. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/README.md +0 -0
  14. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/__init__.py +0 -0
  15. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/__main__.py +0 -0
  16. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/__init__.py +0 -0
  17. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/bootstrap.py +0 -0
  18. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/config_store.py +0 -0
  19. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/data.py +0 -0
  20. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/debug.py +0 -0
  21. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/icon.py +0 -0
  22. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/init.py +0 -0
  23. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/instance.py +0 -0
  24. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/package_state.py +0 -0
  25. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/qt.py +0 -0
  26. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/schema.py +0 -0
  27. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/__init__.py +0 -0
  28. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/card.py +0 -0
  29. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/install.py +0 -0
  30. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/install_workers.py +0 -0
  31. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/log_panel.py +0 -0
  32. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/plugin_row.py +0 -0
  33. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/projects.py +0 -0
  34. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/schema.py +0 -0
  35. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/settings.py +0 -0
  36. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/sidebar.py +0 -0
  37. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/spinner.py +0 -0
  38. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/tool_update_controller.py +0 -0
  39. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/screen/update_banner.py +0 -0
  40. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/update_controller.py +0 -0
  41. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/update_model.py +0 -0
  42. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/application/uri.py +0 -0
  43. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/cli/__init__.py +0 -0
  44. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/cli/config.py +0 -0
  45. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/cli/context.py +0 -0
  46. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/cli/debug.py +0 -0
  47. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/cli/install.py +0 -0
  48. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/cli/output.py +0 -0
  49. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/cli/project.py +0 -0
  50. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/cli/tool.py +0 -0
  51. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/cli/update.py +0 -0
  52. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/client.py +0 -0
  53. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/config.py +0 -0
  54. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/logging.py +0 -0
  55. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/operations/__init__.py +0 -0
  56. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/operations/bootstrap.py +0 -0
  57. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/operations/config.py +0 -0
  58. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/operations/project.py +0 -0
  59. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/operations/tool.py +0 -0
  60. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/operations/update.py +0 -0
  61. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/protocol.py +0 -0
  62. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/py.typed +0 -0
  63. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/resolution.py +0 -0
  64. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/schema.py +0 -0
  65. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/startup.py +0 -0
  66. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/synodic_client/subprocess_patch.py +0 -0
  67. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/__init__.py +0 -0
  68. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/conftest.py +0 -0
  69. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/__init__.py +0 -0
  70. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/operations/__init__.py +0 -0
  71. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/operations/test_config.py +0 -0
  72. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/operations/test_install.py +0 -0
  73. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/operations/test_install_plan.py +0 -0
  74. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/operations/test_project.py +0 -0
  75. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/operations/test_tool.py +0 -0
  76. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/operations/test_update.py +0 -0
  77. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/__init__.py +0 -0
  78. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/conftest.py +0 -0
  79. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_action_card.py +0 -0
  80. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_gather_packages.py +0 -0
  81. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_install_preview.py +0 -0
  82. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_log_panel.py +0 -0
  83. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_logging.py +0 -0
  84. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_package_state.py +0 -0
  85. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_preview_model.py +0 -0
  86. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_settings.py +0 -0
  87. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_sidebar.py +0 -0
  88. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_update_banner.py +0 -0
  89. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_update_controller.py +0 -0
  90. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/qt/test_update_feedback.py +0 -0
  91. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_bootstrap.py +0 -0
  92. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_cli.py +0 -0
  93. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_client_updater.py +0 -0
  94. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_client_version.py +0 -0
  95. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_config.py +0 -0
  96. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_examples.py +0 -0
  97. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_init.py +0 -0
  98. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_install.py +0 -0
  99. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_resolution.py +0 -0
  100. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_updater.py +0 -0
  101. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_uri.py +0 -0
  102. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/test_workers.py +0 -0
  103. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/windows/__init__.py +0 -0
  104. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/windows/conftest.py +0 -0
  105. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/windows/test_protocol.py +0 -0
  106. {synodic_client-0.0.1.dev80 → synodic_client-0.0.1.dev82}/tests/unit/windows/test_startup.py +0 -0
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev80
3
+ Version: 0.0.1.dev82
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
7
7
  Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
- Requires-Dist: pyside6>=6.10.2
9
+ Requires-Dist: pyside6>=6.11.0
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev86
11
+ Requires-Dist: porringer>=0.2.1.dev88
12
12
  Requires-Dist: qasync>=0.28.0
13
- Requires-Dist: velopack>=0.0.1521.dev61717
13
+ Requires-Dist: velopack>=0.0.1535.dev45597
14
14
  Requires-Dist: typer>=0.24.1
15
15
  Description-Content-Type: text/markdown
16
16
 
@@ -8,14 +8,14 @@ readme = "README.md"
8
8
  dynamic = []
9
9
  requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
- "pyside6>=6.10.2",
11
+ "pyside6>=6.11.0",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev86",
13
+ "porringer>=0.2.1.dev88",
14
14
  "qasync>=0.28.0",
15
- "velopack>=0.0.1521.dev61717",
15
+ "velopack>=0.0.1535.dev45597",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev80"
18
+ version = "0.0.1.dev82"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -36,7 +36,7 @@ build = [
36
36
  ]
37
37
  lint = [
38
38
  "ruff>=0.15.7",
39
- "pyrefly>=0.57.1",
39
+ "pyrefly>=0.58.0",
40
40
  ]
41
41
  test = [
42
42
  "pytest>=9.0.2",
@@ -40,6 +40,7 @@ from synodic_client.application.screen.spinner import SpinnerCanvas
40
40
  from synodic_client.application.theme import (
41
41
  ACTION_CARD_COMMAND_STYLE,
42
42
  ACTION_CARD_DESC_STYLE,
43
+ ACTION_CARD_DISTRO_BADGE_STYLE,
43
44
  ACTION_CARD_EXECUTING_STYLE,
44
45
  ACTION_CARD_PACKAGE_STYLE,
45
46
  ACTION_CARD_SKELETON_BAR_STYLE,
@@ -64,6 +65,7 @@ from synodic_client.application.theme import (
64
65
  COPY_BTN_STYLE,
65
66
  COPY_FEEDBACK_MS,
66
67
  COPY_ICON,
68
+ WSL_DISTRO_HEADER_STYLE,
67
69
  )
68
70
 
69
71
  logger = logging.getLogger(__name__)
@@ -224,7 +226,7 @@ class ActionCard(QFrame):
224
226
  outer.addWidget(self._build_command_row())
225
227
 
226
228
  def _build_top_row(self) -> QHBoxLayout:
227
- """Build the top row: type badge | package name ... version | status/spinner | prerelease."""
229
+ """Build the top row: type badge | [distro badge] | package name ... version | status/spinner | prerelease."""
228
230
  top = QHBoxLayout()
229
231
  top.setSpacing(8)
230
232
 
@@ -232,6 +234,11 @@ class ActionCard(QFrame):
232
234
  self._type_badge.setStyleSheet(ACTION_CARD_TYPE_BADGE_STYLE)
233
235
  top.addWidget(self._type_badge)
234
236
 
237
+ self._distro_badge = QLabel()
238
+ self._distro_badge.setStyleSheet(ACTION_CARD_DISTRO_BADGE_STYLE)
239
+ self._distro_badge.hide()
240
+ top.addWidget(self._distro_badge)
241
+
235
242
  self._package_label = QLabel()
236
243
  self._package_label.setStyleSheet(ACTION_CARD_PACKAGE_STYLE)
237
244
  self._package_label.setTextInteractionFlags(
@@ -360,6 +367,12 @@ class ActionCard(QFrame):
360
367
  if action.installer:
361
368
  self._type_badge.setToolTip(f'Plugin: {action.installer}')
362
369
 
370
+ if action.distro:
371
+ self._distro_badge.setText(action.distro)
372
+ self._distro_badge.show()
373
+ else:
374
+ self._distro_badge.hide()
375
+
363
376
  package_text = str(action.package) if action.package else action.description
364
377
  self._package_label.setText(package_text)
365
378
 
@@ -635,6 +648,32 @@ class ActionCard(QFrame):
635
648
  super().mousePressEvent(event)
636
649
 
637
650
 
651
+ # ---------------------------------------------------------------------------
652
+ # _DistroGroupHeader — section divider between native and WSL action groups
653
+ # ---------------------------------------------------------------------------
654
+
655
+
656
+ class _DistroGroupHeader(QLabel):
657
+ """Thin section divider between native and per-distro action groups.
658
+
659
+ Shows ``HOST`` for the native group or ``WSL — <distro>`` for each
660
+ WSL2 distro group. Only inserted when both native and WSL actions
661
+ are present.
662
+ """
663
+
664
+ def __init__(self, distro_name: str | None, parent: QWidget | None = None) -> None:
665
+ """Initialise the header.
666
+
667
+ Args:
668
+ distro_name: WSL distro name, or ``None`` for the native group.
669
+ parent: Optional parent widget.
670
+ """
671
+ label = 'HOST' if distro_name is None else f'WSL \u2014 {distro_name}'.upper()
672
+ super().__init__(label, parent)
673
+ self.setObjectName('wslDistroHeader')
674
+ self.setStyleSheet(WSL_DISTRO_HEADER_STYLE)
675
+
676
+
638
677
  # ---------------------------------------------------------------------------
639
678
  # ActionCardList — card container
640
679
  # ---------------------------------------------------------------------------
@@ -667,6 +706,7 @@ class ActionCardList(QWidget):
667
706
  self._layout.addStretch()
668
707
 
669
708
  self._cards: list[ActionCard] = []
709
+ self._group_headers: list[QLabel] = []
670
710
  self._action_map: dict[SetupAction, ActionCard] = {}
671
711
  self._index_map: dict[int, ActionCard] = {}
672
712
 
@@ -701,25 +741,58 @@ class ActionCardList(QWidget):
701
741
  ) -> None:
702
742
  """Replace skeleton cards with real action cards.
703
743
 
744
+ When actions include WSL distro entries (``action.distro is not
745
+ None``), the list is split into groups. Native host actions
746
+ appear first under a ``HOST`` section header; each WSL distro
747
+ gets its own ``WSL — <distro>`` section header below. If all
748
+ actions are native, no headers are inserted.
749
+
704
750
  Args:
705
751
  actions: The setup actions to display.
706
752
  plugin_installed: Plugin name → installed mapping.
707
753
  prerelease_overrides: Package names with user pre-release overrides.
708
754
  """
709
755
  self.clear()
710
- sorted_actions = sorted(actions, key=action_sort_key)
711
- for act in sorted_actions:
712
- card = ActionCard(self)
713
- card.populate(
714
- act,
715
- plugin_installed=plugin_installed,
716
- prerelease_overrides=prerelease_overrides,
717
- )
718
- card.prerelease_toggled.connect(self.prerelease_toggled.emit)
719
- card.navigate_to_tool.connect(self.navigate_to_tool.emit)
720
- self._layout.insertWidget(self._layout.count() - 1, card)
721
- self._cards.append(card)
722
- self._action_map[act] = card
756
+
757
+ # Partition into native and per-distro groups.
758
+ native_actions: list[SetupAction] = []
759
+ distro_actions: dict[str, list[SetupAction]] = {}
760
+ for act in actions:
761
+ if act.distro is None:
762
+ native_actions.append(act)
763
+ else:
764
+ distro_actions.setdefault(act.distro, []).append(act)
765
+
766
+ has_wsl = bool(distro_actions)
767
+
768
+ def _add_group(group_actions: list[SetupAction]) -> None:
769
+ for act in sorted(group_actions, key=action_sort_key):
770
+ card = ActionCard(self)
771
+ card.populate(
772
+ act,
773
+ plugin_installed=plugin_installed,
774
+ prerelease_overrides=prerelease_overrides,
775
+ )
776
+ card.prerelease_toggled.connect(self.prerelease_toggled.emit)
777
+ card.navigate_to_tool.connect(self.navigate_to_tool.emit)
778
+ self._layout.insertWidget(self._layout.count() - 1, card)
779
+ self._cards.append(card)
780
+ self._action_map[act] = card
781
+
782
+ def _add_header(distro_name: str | None) -> None:
783
+ header = _DistroGroupHeader(distro_name, self)
784
+ self._layout.insertWidget(self._layout.count() - 1, header)
785
+ self._group_headers.append(header)
786
+
787
+ # Native actions (with host header only when WSL groups also exist)
788
+ if has_wsl and native_actions:
789
+ _add_header(None)
790
+ _add_group(native_actions)
791
+
792
+ # Per-distro groups
793
+ for distro_name in sorted(distro_actions):
794
+ _add_header(distro_name)
795
+ _add_group(distro_actions[distro_name])
723
796
 
724
797
  # Build original-index → card mapping so callers can look up by
725
798
  # the action index porringer emits, which is independent of the
@@ -776,7 +849,11 @@ class ActionCardList(QWidget):
776
849
  card.finalize_checking()
777
850
 
778
851
  def clear(self) -> None:
779
- """Remove all cards."""
852
+ """Remove all cards and group headers."""
853
+ for header in self._group_headers:
854
+ self._layout.removeWidget(header)
855
+ header.deleteLater()
856
+ self._group_headers.clear()
780
857
  for card in self._cards:
781
858
  self._layout.removeWidget(card)
782
859
  card.deleteLater()
@@ -50,6 +50,7 @@ from synodic_client.application.screen.schema import (
50
50
  )
51
51
  from synodic_client.application.screen.spinner import LoadingIndicator
52
52
  from synodic_client.application.screen.update_banner import UpdateBanner
53
+ from synodic_client.application.screen.wsl import WslView
53
54
  from synodic_client.application.theme import (
54
55
  COMPACT_MARGINS,
55
56
  FILTER_CHIP_SPACING,
@@ -1434,6 +1435,7 @@ class MainWindow(QMainWindow):
1434
1435
  _tabs: QTabWidget | None = None
1435
1436
  _tools_view: ToolsView | None = None
1436
1437
  _projects_view: ProjectsView | None = None
1438
+ _wsl_view: WslView | None = None
1437
1439
 
1438
1440
  def __init__(
1439
1441
  self,
@@ -1517,6 +1519,22 @@ class MainWindow(QMainWindow):
1517
1519
  self._tabs.addTab(self._tools_view, 'Tools')
1518
1520
  self.tools_view_created.emit(self._tools_view)
1519
1521
 
1522
+ # WSL tab — only on Windows hosts with WSL available.
1523
+ try:
1524
+ from porringer.plugin.wsl.utility import is_wsl_host
1525
+
1526
+ if is_wsl_host():
1527
+ self._wsl_view = WslView(
1528
+ self._porringer,
1529
+ self._store,
1530
+ self,
1531
+ coordinator=self._coordinator,
1532
+ package_store=self._package_store,
1533
+ )
1534
+ self._tabs.addTab(self._wsl_view, 'WSL')
1535
+ except Exception:
1536
+ logger.debug('Could not initialise WSL tab', exc_info=True)
1537
+
1520
1538
  # Navigate-to-project: switch to Projects tab and select directory
1521
1539
  self._tools_view.navigate_to_project_requested.connect(self._navigate_to_project)
1522
1540
 
@@ -1546,6 +1564,8 @@ class MainWindow(QMainWindow):
1546
1564
  self._tools_view.refresh()
1547
1565
  if self._projects_view is not None:
1548
1566
  self._projects_view.refresh()
1567
+ if self._wsl_view is not None:
1568
+ self._wsl_view.refresh()
1549
1569
 
1550
1570
  def _navigate_to_project(self, path_str: str) -> None:
1551
1571
  """Switch to the Projects tab and select the given directory."""
@@ -4,7 +4,7 @@ import logging
4
4
  from typing import TYPE_CHECKING
5
5
 
6
6
  from PySide6.QtCore import QTimer
7
- from PySide6.QtGui import QAction
7
+ from PySide6.QtGui import QAction, QCursor
8
8
  from PySide6.QtWidgets import (
9
9
  QApplication,
10
10
  QMainWindow,
@@ -126,10 +126,10 @@ class TrayScreen:
126
126
  self._menu.addSeparator()
127
127
 
128
128
  self._quit_action = QAction('Quit', self._menu)
129
- self._quit_action.triggered.connect(app.quit)
129
+ self._quit_action.triggered.connect(self._on_quit_triggered)
130
130
  self._menu.addAction(self._quit_action)
131
131
 
132
- self.tray.setContextMenu(self._menu)
132
+ self._menu.aboutToShow.connect(lambda: logger.debug('Tray context menu about to show'))
133
133
 
134
134
  # Maximum number of tray-visibility retries at startup.
135
135
  _TRAY_MAX_RETRIES = 5
@@ -162,12 +162,29 @@ class TrayScreen:
162
162
  )
163
163
  self.tray.setVisible(True)
164
164
 
165
+ # Delay before showing the context menu, in milliseconds.
166
+ # Absorbs residual mouse-up events from touchpad two-finger taps
167
+ # that would otherwise land on a menu item (typically "Quit").
168
+ _MENU_POPUP_DELAY_MS = 80
169
+
165
170
  def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
166
- """Handle tray icon activation (e.g. double-click)."""
171
+ """Handle tray icon activation."""
172
+ logger.debug('Tray activated: reason=%s', reason.name)
167
173
  if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
168
174
  self._window.show()
169
175
  self._window.raise_()
170
176
  self._window.activateWindow()
177
+ elif reason == QSystemTrayIcon.ActivationReason.Context:
178
+ QTimer.singleShot(self._MENU_POPUP_DELAY_MS, self._show_tray_menu)
179
+
180
+ def _show_tray_menu(self) -> None:
181
+ """Show the tray context menu at the current cursor position."""
182
+ self._menu.exec(QCursor.pos())
183
+
184
+ def _on_quit_triggered(self) -> None:
185
+ """Handle the Quit menu action."""
186
+ logger.info('Quit requested via tray menu')
187
+ self._app.quit()
171
188
 
172
189
  def _show_settings(self) -> None:
173
190
  """Show the settings window."""
@@ -0,0 +1,419 @@
1
+ """WSL package management view.
2
+
3
+ Shows packages installed in each WSL2 distro, using the same plugin row
4
+ widgets as :class:`~synodic_client.application.screen.screen.ToolsView`
5
+ but scoped per-distro.
6
+
7
+ Only rendered when :func:`porringer.plugin.wsl.utility.is_wsl_host`
8
+ returns ``True`` (Windows + ``wsl`` on PATH).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ from typing import TYPE_CHECKING
16
+
17
+ from porringer.schema.plugin import PluginKind
18
+ from PySide6.QtCore import Qt, Signal
19
+ from PySide6.QtWidgets import (
20
+ QHBoxLayout,
21
+ QLabel,
22
+ QPushButton,
23
+ QScrollArea,
24
+ QVBoxLayout,
25
+ QWidget,
26
+ )
27
+
28
+ from synodic_client.application.screen.plugin_row import (
29
+ PluginProviderHeader,
30
+ PluginRow,
31
+ RowPhase,
32
+ )
33
+ from synodic_client.application.screen.schema import PackageEntry, PluginRowData
34
+ from synodic_client.application.screen.spinner import LoadingIndicator
35
+ from synodic_client.application.theme import (
36
+ COMPACT_MARGINS,
37
+ PLUGIN_KIND_HEADER_STYLE,
38
+ PLUGIN_SECTION_SPACING,
39
+ )
40
+
41
+ if TYPE_CHECKING:
42
+ from porringer.api import API
43
+ from porringer.schema import PluginInfo
44
+
45
+ from synodic_client.application.config_store import ConfigStore
46
+ from synodic_client.application.data import DataCoordinator
47
+ from synodic_client.application.package_state import PackageStateStore
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ #: Plugin kinds queried when listing packages inside a WSL distro.
52
+ _WSL_QUERYABLE_KINDS = frozenset({PluginKind.TOOL, PluginKind.PACKAGE})
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # _WslDistroHeader — section divider for one WSL distro
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ class _WslDistroHeader(QLabel):
61
+ """Uppercase section divider labelling a WSL2 distro group.
62
+
63
+ Styled like :class:`PluginKindHeader` but with a purple tint to
64
+ visually distinguish WSL sections from native host sections.
65
+ """
66
+
67
+ def __init__(self, distro_name: str, parent: QWidget | None = None) -> None:
68
+ super().__init__(distro_name.upper(), parent)
69
+ self.setObjectName('pluginKindHeader')
70
+ self.setStyleSheet(PLUGIN_KIND_HEADER_STYLE)
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # WslView
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ class WslView(QWidget):
79
+ """Tool management view for packages installed inside WSL2 distros.
80
+
81
+ Mirrors the structure of ``ToolsView`` — distro section headers,
82
+ per-plugin :class:`PluginProviderHeader` rows, and per-package
83
+ :class:`PluginRow` widgets — but queries are routed through the
84
+ ``distro=`` parameter on :meth:`porringer.PackageCommands.list`,
85
+ :meth:`~porringer.PackageCommands.upgrade`, and
86
+ :meth:`~porringer.PackageCommands.uninstall`.
87
+
88
+ Each distro's packages are loaded in parallel. Packages are
89
+ displayed globally (WSL environments don't have per-project venvs
90
+ in the same sense as the host).
91
+ """
92
+
93
+ package_update_requested = Signal(str, str, str)
94
+ """Emitted with ``(distro, plugin_name, package_name)`` when update is clicked."""
95
+
96
+ package_remove_requested = Signal(str, str, str)
97
+ """Emitted with ``(distro, plugin_name, package_name)`` when remove is clicked."""
98
+
99
+ def __init__(
100
+ self,
101
+ porringer: API,
102
+ store: ConfigStore,
103
+ parent: QWidget | None = None,
104
+ *,
105
+ coordinator: DataCoordinator | None = None,
106
+ package_store: PackageStateStore | None = None,
107
+ ) -> None:
108
+ """Initialize the WSL view.
109
+
110
+ Args:
111
+ porringer: The porringer API instance.
112
+ store: The centralised :class:`ConfigStore`.
113
+ parent: Optional parent widget.
114
+ coordinator: Shared data coordinator for reusing discovery results.
115
+ package_store: Shared package update state registry (currently unused
116
+ by WslView but accepted for API consistency with ToolsView).
117
+ """
118
+ super().__init__(parent)
119
+ self._porringer = porringer
120
+ self._store = store
121
+ self._coordinator = coordinator
122
+ self._section_widgets: list[QWidget] = []
123
+ self._plugin_info_map: dict[str, PluginInfo] = {}
124
+ self._refresh_in_progress = False
125
+ self._init_ui()
126
+
127
+ # ------------------------------------------------------------------
128
+ # UI construction
129
+ # ------------------------------------------------------------------
130
+
131
+ def _init_ui(self) -> None:
132
+ """Build the view layout: toolbar, scroll area, loading indicator."""
133
+ outer = QVBoxLayout(self)
134
+ outer.setContentsMargins(*COMPACT_MARGINS)
135
+
136
+ outer.addLayout(self._build_toolbar())
137
+
138
+ self._scroll = QScrollArea()
139
+ self._scroll.setWidgetResizable(True)
140
+ self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
141
+
142
+ self._container = QWidget()
143
+ self._container_layout = QVBoxLayout(self._container)
144
+ self._container_layout.setSpacing(PLUGIN_SECTION_SPACING)
145
+ self._container_layout.setContentsMargins(0, 0, 0, 0)
146
+ self._container_layout.addStretch()
147
+
148
+ self._scroll.setWidget(self._container)
149
+ outer.addWidget(self._scroll)
150
+
151
+ self._loading_indicator = LoadingIndicator('Loading WSL packages\u2026')
152
+ outer.addWidget(self._loading_indicator)
153
+
154
+ def _build_toolbar(self) -> QHBoxLayout:
155
+ """Build the toolbar with a refresh button."""
156
+ toolbar = QHBoxLayout()
157
+ toolbar.addStretch()
158
+
159
+ self._refresh_btn = QPushButton('Refresh')
160
+ self._refresh_btn.setFlat(True)
161
+ self._refresh_btn.setToolTip('Re-scan WSL packages')
162
+ self._refresh_btn.clicked.connect(self.refresh)
163
+ toolbar.addWidget(self._refresh_btn)
164
+
165
+ return toolbar
166
+
167
+ # ------------------------------------------------------------------
168
+ # Public API
169
+ # ------------------------------------------------------------------
170
+
171
+ def refresh(self) -> None:
172
+ """Schedule an asynchronous rebuild of the WSL package list."""
173
+ if self._refresh_in_progress:
174
+ return
175
+ asyncio.create_task(self._async_refresh())
176
+
177
+ # ------------------------------------------------------------------
178
+ # Async refresh pipeline
179
+ # ------------------------------------------------------------------
180
+
181
+ async def _async_refresh(self) -> None:
182
+ """Rebuild the WSL package tree from porringer data."""
183
+ self._refresh_in_progress = True
184
+ self._scroll.hide()
185
+ self._loading_indicator.start()
186
+ self._refresh_btn.setEnabled(False)
187
+
188
+ try:
189
+ data = await self._gather_wsl_data()
190
+ self._build_widget_tree(data)
191
+ except Exception:
192
+ logger.exception('Failed to refresh WSL packages')
193
+ finally:
194
+ self._loading_indicator.stop()
195
+ self._scroll.show()
196
+ self._refresh_btn.setEnabled(True)
197
+ self._refresh_in_progress = False
198
+
199
+ async def _gather_wsl_data(self) -> dict[str, dict[str, list[PackageEntry]]]:
200
+ """Fetch packages for every available WSL2 distro.
201
+
202
+ For each distro, all TOOL- and PACKAGE-kind plugins are queried
203
+ in parallel using ``porringer.package.list(..., distro=distro)``.
204
+
205
+ Returns:
206
+ ``{distro_name: {plugin_name: [PackageEntry, ...]}}`` for
207
+ distros that have at least one non-empty plugin result.
208
+ """
209
+ from porringer.plugin.wsl.utility import available_distros
210
+
211
+ discovered = self._coordinator.discovered_plugins if self._coordinator else None
212
+
213
+ if self._coordinator is not None:
214
+ snapshot = await self._coordinator.refresh()
215
+ plugins = snapshot.plugins
216
+ else:
217
+ plugins = await self._porringer.plugin.list()
218
+
219
+ # Update the plugin-info lookup for PluginProviderHeader construction.
220
+ self._plugin_info_map = {p.name: p for p in plugins}
221
+
222
+ queryable = [p for p in plugins if p.kind in _WSL_QUERYABLE_KINDS and p.installed]
223
+
224
+ loop = asyncio.get_running_loop()
225
+ distros: list[str] = await loop.run_in_executor(None, available_distros)
226
+
227
+ result: dict[str, dict[str, list[PackageEntry]]] = {}
228
+
229
+ async def _fetch_distro(distro: str) -> None:
230
+ distro_pkgs: dict[str, list[PackageEntry]] = {}
231
+
232
+ async def _fetch_plugin(plugin: PluginInfo) -> None:
233
+ try:
234
+ pkgs = await self._porringer.package.list(
235
+ plugin.name,
236
+ plugins=discovered,
237
+ distro=distro,
238
+ )
239
+ if pkgs:
240
+ distro_pkgs[plugin.name] = [
241
+ PackageEntry(
242
+ name=str(pkg.name),
243
+ version=str(pkg.version) if pkg.version else '',
244
+ host_tool=pkg.relation.host if pkg.relation else '',
245
+ )
246
+ for pkg in pkgs
247
+ ]
248
+ except Exception:
249
+ logger.debug(
250
+ 'Could not list packages for %s in distro %s',
251
+ plugin.name,
252
+ distro,
253
+ exc_info=True,
254
+ )
255
+
256
+ async with asyncio.TaskGroup() as tg:
257
+ for plugin in queryable:
258
+ tg.create_task(_fetch_plugin(plugin))
259
+
260
+ if distro_pkgs:
261
+ result[distro] = distro_pkgs
262
+
263
+ async with asyncio.TaskGroup() as tg:
264
+ for distro in distros:
265
+ tg.create_task(_fetch_distro(distro))
266
+
267
+ return result
268
+
269
+ # ------------------------------------------------------------------
270
+ # Widget tree construction
271
+ # ------------------------------------------------------------------
272
+
273
+ def _build_widget_tree(self, data: dict[str, dict[str, list[PackageEntry]]]) -> None:
274
+ """Clear and rebuild section widgets from *data*."""
275
+ self._clear_section_widgets()
276
+
277
+ if not data:
278
+ empty = QLabel('No WSL packages found.')
279
+ empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
280
+ empty.setStyleSheet('color: grey; padding: 20px;')
281
+ self._insert_section_widget(empty)
282
+ return
283
+
284
+ for distro_name in sorted(data):
285
+ header = _WslDistroHeader(distro_name, parent=self._container)
286
+ self._insert_section_widget(header)
287
+
288
+ for plugin_name, packages in sorted(data[distro_name].items()):
289
+ plugin_info = self._plugin_info_map.get(plugin_name)
290
+ if plugin_info is not None:
291
+ provider = PluginProviderHeader(
292
+ plugin_info,
293
+ show_controls=False,
294
+ parent=self._container,
295
+ )
296
+ self._insert_section_widget(provider)
297
+
298
+ for entry in packages:
299
+ row = PluginRow(
300
+ PluginRowData(
301
+ name=entry.name,
302
+ version=entry.version,
303
+ plugin_name=plugin_name,
304
+ show_toggle=False,
305
+ is_global=True,
306
+ host_tool=entry.host_tool,
307
+ ),
308
+ parent=self._container,
309
+ )
310
+ # Capture distro_name in the lambda so the handler
311
+ # knows which distro to route the operation to.
312
+ row.update_requested.connect(
313
+ lambda pn, pkg, d=distro_name: asyncio.create_task(
314
+ self._update_package(d, pn, pkg),
315
+ ),
316
+ )
317
+ row.remove_requested.connect(
318
+ lambda pn, pkg, d=distro_name: asyncio.create_task(
319
+ self._remove_package(d, pn, pkg),
320
+ ),
321
+ )
322
+ self._insert_section_widget(row)
323
+
324
+ def _insert_section_widget(self, widget: QWidget) -> None:
325
+ """Append *widget* to the container layout above the trailing stretch."""
326
+ idx = self._container_layout.count() - 1
327
+ self._container_layout.insertWidget(idx, widget)
328
+ self._section_widgets.append(widget)
329
+
330
+ def _clear_section_widgets(self) -> None:
331
+ """Remove and delete all current section widgets."""
332
+ for widget in self._section_widgets:
333
+ self._container_layout.removeWidget(widget)
334
+ widget.deleteLater()
335
+ self._section_widgets.clear()
336
+
337
+ # ------------------------------------------------------------------
338
+ # Package operations
339
+ # ------------------------------------------------------------------
340
+
341
+ async def _update_package(self, distro: str, plugin_name: str, package_name: str) -> None:
342
+ """Upgrade *package_name* inside *distro* via *plugin_name*.
343
+
344
+ Transitions the matching :class:`PluginRow` to the ``UPDATING``
345
+ phase, executes the upgrade, then returns it to ``IDLE`` or
346
+ shows an inline error on failure.
347
+ """
348
+ from porringer.core.schema import PackageRef
349
+
350
+ discovered = self._coordinator.discovered_plugins if self._coordinator else None
351
+ row = self._find_row(plugin_name, package_name)
352
+ if row is not None:
353
+ row.set_phase(RowPhase.UPDATING)
354
+
355
+ try:
356
+ ref = PackageRef(name=package_name)
357
+ action_result = await self._porringer.package.upgrade(
358
+ plugin_name,
359
+ ref,
360
+ plugins=discovered,
361
+ distro=distro,
362
+ )
363
+ if not action_result.success and not action_result.skipped:
364
+ msg = action_result.message or 'Update failed'
365
+ logger.warning('WSL package update failed: %s/%s in %s: %s', plugin_name, package_name, distro, msg)
366
+ if row is not None:
367
+ row.set_error(msg)
368
+ else:
369
+ if row is not None:
370
+ row.set_phase(RowPhase.IDLE)
371
+ self.package_update_requested.emit(distro, plugin_name, package_name)
372
+ except Exception:
373
+ logger.exception('WSL update error: %s/%s in %s', plugin_name, package_name, distro)
374
+ if row is not None:
375
+ row.set_error('Update error — see log')
376
+
377
+ async def _remove_package(self, distro: str, plugin_name: str, package_name: str) -> None:
378
+ """Uninstall *package_name* from *distro* via *plugin_name*.
379
+
380
+ On success, triggers a refresh so the removed package disappears
381
+ from the list. On failure, shows an inline error on the row.
382
+ """
383
+ from porringer.core.schema import PackageRef
384
+
385
+ discovered = self._coordinator.discovered_plugins if self._coordinator else None
386
+ row = self._find_row(plugin_name, package_name)
387
+
388
+ try:
389
+ ref = PackageRef(name=package_name)
390
+ action_result = await self._porringer.package.uninstall(
391
+ plugin_name,
392
+ ref,
393
+ plugins=discovered,
394
+ distro=distro,
395
+ )
396
+ if action_result.success or action_result.skipped:
397
+ self.package_remove_requested.emit(distro, plugin_name, package_name)
398
+ # Refresh to remove the row from the list.
399
+ self.refresh()
400
+ else:
401
+ msg = action_result.message or 'Removal failed'
402
+ logger.warning('WSL package removal failed: %s/%s in %s: %s', plugin_name, package_name, distro, msg)
403
+ if row is not None:
404
+ row.set_error(msg)
405
+ except Exception:
406
+ logger.exception('WSL removal error: %s/%s in %s', plugin_name, package_name, distro)
407
+ if row is not None:
408
+ row.set_error('Removal error — see log')
409
+
410
+ def _find_row(self, plugin_name: str, package_name: str) -> PluginRow | None:
411
+ """Return the first :class:`PluginRow` matching *plugin_name* and *package_name*."""
412
+ for widget in self._section_widgets:
413
+ if (
414
+ isinstance(widget, PluginRow)
415
+ and widget._plugin_name == plugin_name
416
+ and widget._package_name == package_name
417
+ ):
418
+ return widget
419
+ return None
@@ -448,6 +448,22 @@ ACTION_CARD_TYPE_BADGE_STYLE = (
448
448
  )
449
449
  """Small type badge (Package, Tool, Runtime, etc.) on each action card."""
450
450
 
451
+ ACTION_CARD_DISTRO_BADGE_STYLE = (
452
+ 'QLabel { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: #3d2a50; color: #c5a0e0; }'
453
+ )
454
+ """Muted purple pill showing the WSL distro name on a WSL action card."""
455
+
456
+ WSL_DISTRO_HEADER_STYLE = (
457
+ 'QLabel#wslDistroHeader {'
458
+ ' font-size: 11px;'
459
+ ' font-weight: bold;'
460
+ ' color: #c5a0e0;'
461
+ ' padding: 8px 4px 4px 4px;'
462
+ ' border-bottom: 1px solid palette(mid);'
463
+ '}'
464
+ )
465
+ """Section divider for WSL distro groups inside the action card list."""
466
+
451
467
  ACTION_CARD_PACKAGE_STYLE = 'font-weight: bold; font-size: 12px;'
452
468
  """Primary line: package name."""
453
469
 
@@ -225,6 +225,7 @@ async def preview_manifest(
225
225
  package=str(act.package.name) if act.package else None,
226
226
  constraint=act.package.constraint if act.package else None,
227
227
  installer=act.installer,
228
+ distro=act.distro,
228
229
  )
229
230
  )
230
231
 
@@ -355,6 +355,8 @@ class ActionInfo:
355
355
  package: str | None = None
356
356
  constraint: str | None = None
357
357
  installer: str | None = None
358
+ distro: str | None = None
359
+ """WSL2 distro name, or ``None`` for native host actions."""
358
360
 
359
361
 
360
362
  @dataclass(frozen=True, slots=True)
@@ -262,10 +262,9 @@ class Updater:
262
262
  # Simple semver comparison via packaging.version (accepts
263
263
  # semver pre-release tags like ``0.1.0-dev.79``).
264
264
  try:
265
- if Version(ver) > Version(current_semver):
266
- if best_ver is None or Version(ver) > Version(best_ver):
267
- best = asset
268
- best_ver = ver
265
+ if Version(ver) > Version(current_semver) and (best_ver is None or Version(ver) > Version(best_ver)):
266
+ best = asset
267
+ best_ver = ver
269
268
  except Exception:
270
269
  continue
271
270
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from unittest.mock import MagicMock, patch
6
6
 
7
7
  import pytest
8
+ from PySide6.QtWidgets import QSystemTrayIcon
8
9
 
9
10
  from synodic_client.application.screen.schema import UpdateTarget
10
11
  from synodic_client.application.screen.tray import TrayScreen
@@ -69,3 +70,32 @@ class TestToolUpdateWindowShow:
69
70
  result = UpdateResult(manifests_processed=1, already_latest=['pkg'])
70
71
  tray_screen._tool_orchestrator._on_tool_update_finished(result)
71
72
  tray_screen._window.show.assert_not_called()
73
+
74
+
75
+ class TestTrayActivation:
76
+ """_on_tray_activated should dispatch correctly by reason."""
77
+
78
+ @staticmethod
79
+ def test_double_click_shows_window(tray_screen) -> None:
80
+ """Double-clicking the tray icon should show and raise the window."""
81
+ tray_screen._on_tray_activated(QSystemTrayIcon.ActivationReason.DoubleClick)
82
+ tray_screen._window.show.assert_called_once()
83
+ tray_screen._window.raise_.assert_called_once()
84
+ tray_screen._window.activateWindow.assert_called_once()
85
+
86
+ @staticmethod
87
+ def test_context_defers_menu_via_timer(tray_screen) -> None:
88
+ """Right-clicking should defer the menu popup via a single-shot timer."""
89
+ with patch('synodic_client.application.screen.tray.QTimer') as mock_timer:
90
+ tray_screen._on_tray_activated(QSystemTrayIcon.ActivationReason.Context)
91
+ mock_timer.singleShot.assert_called_once()
92
+ delay, callback = mock_timer.singleShot.call_args[0]
93
+ assert delay == TrayScreen._MENU_POPUP_DELAY_MS
94
+ assert callback == tray_screen._show_tray_menu
95
+
96
+ @staticmethod
97
+ def test_context_does_not_show_window(tray_screen) -> None:
98
+ """Right-clicking should not bring the main window forward."""
99
+ with patch('synodic_client.application.screen.tray.QTimer'):
100
+ tray_screen._on_tray_activated(QSystemTrayIcon.ActivationReason.Context)
101
+ tray_screen._window.show.assert_not_called()