synodic-client 0.0.1.dev48__tar.gz → 0.0.1.dev50__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 (77) hide show
  1. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/pyproject.toml +2 -2
  3. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/__init__.py +2 -2
  4. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/bootstrap.py +2 -4
  5. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/data.py +2 -28
  6. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/init.py +7 -4
  7. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/qt.py +2 -4
  8. synodic_client-0.0.1.dev50/synodic_client/application/schema.py +53 -0
  9. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/__init__.py +38 -2
  10. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/action_card.py +10 -64
  11. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/install.py +12 -435
  12. synodic_client-0.0.1.dev50/synodic_client/application/screen/install_workers.py +258 -0
  13. synodic_client-0.0.1.dev50/synodic_client/application/screen/plugin_row.py +595 -0
  14. synodic_client-0.0.1.dev50/synodic_client/application/screen/projects.py +268 -0
  15. synodic_client-0.0.1.dev50/synodic_client/application/screen/schema.py +363 -0
  16. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/screen.py +85 -987
  17. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/settings.py +15 -3
  18. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/sidebar.py +1 -1
  19. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/spinner.py +45 -17
  20. synodic_client-0.0.1.dev48/synodic_client/application/screen/tray.py → synodic_client-0.0.1.dev50/synodic_client/application/screen/tool_update_controller.py +66 -124
  21. synodic_client-0.0.1.dev50/synodic_client/application/screen/tray.py +137 -0
  22. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/update_banner.py +1 -25
  23. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/theme.py +34 -1
  24. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/update_controller.py +15 -5
  25. synodic_client-0.0.1.dev50/synodic_client/application/uri.py +80 -0
  26. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/workers.py +2 -14
  27. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/client.py +6 -4
  28. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/config.py +1 -86
  29. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/protocol.py +13 -0
  30. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/resolution.py +6 -59
  31. synodic_client-0.0.1.dev50/synodic_client/schema.py +235 -0
  32. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/updater.py +34 -117
  33. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_gather_packages.py +5 -5
  34. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_install_preview.py +4 -10
  35. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_log_panel.py +2 -1
  36. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_preview_model.py +2 -6
  37. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_settings.py +3 -1
  38. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_sidebar.py +1 -1
  39. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_tray_window_show.py +6 -6
  40. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_update_banner.py +2 -1
  41. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_update_controller.py +6 -4
  42. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_update_feedback.py +7 -5
  43. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_client_updater.py +10 -2
  44. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_init.py +1 -1
  45. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_resolution.py +5 -51
  46. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_updater.py +106 -17
  47. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_workers.py +1 -1
  48. synodic_client-0.0.1.dev48/synodic_client/application/uri.py +0 -24
  49. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/LICENSE.md +0 -0
  50. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/README.md +0 -0
  51. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/__main__.py +0 -0
  52. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/__init__.py +0 -0
  53. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/icon.py +0 -0
  54. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/instance.py +0 -0
  55. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/card.py +0 -0
  56. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/application/screen/log_panel.py +0 -0
  57. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/cli.py +0 -0
  58. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/logging.py +0 -0
  59. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/py.typed +0 -0
  60. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/synodic_client/startup.py +0 -0
  61. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/__init__.py +0 -0
  62. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/conftest.py +0 -0
  63. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/__init__.py +0 -0
  64. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/__init__.py +0 -0
  65. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/conftest.py +0 -0
  66. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_action_card.py +0 -0
  67. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/qt/test_logging.py +0 -0
  68. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_cli.py +0 -0
  69. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_client_version.py +0 -0
  70. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_config.py +0 -0
  71. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_examples.py +0 -0
  72. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_install.py +0 -0
  73. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/test_uri.py +0 -0
  74. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/windows/__init__.py +0 -0
  75. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/windows/conftest.py +0 -0
  76. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/tests/unit/windows/test_protocol.py +0 -0
  77. {synodic_client-0.0.1.dev48 → synodic_client-0.0.1.dev50}/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.dev48
3
+ Version: 0.0.1.dev50
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
@@ -8,7 +8,7 @@ Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
9
  Requires-Dist: pyside6>=6.10.2
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev73
11
+ Requires-Dist: porringer>=0.2.1.dev74
12
12
  Requires-Dist: qasync>=0.28.0
13
13
  Requires-Dist: velopack>=0.0.1444.dev49733
14
14
  Requires-Dist: typer>=0.24.1
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
11
  "pyside6>=6.10.2",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev73",
13
+ "porringer>=0.2.1.dev74",
14
14
  "qasync>=0.28.0",
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev48"
18
+ version = "0.0.1.dev50"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -3,13 +3,13 @@
3
3
  import importlib.metadata
4
4
 
5
5
  from synodic_client.client import Client
6
- from synodic_client.updater import (
6
+ from synodic_client.schema import (
7
7
  UpdateChannel,
8
8
  UpdateConfig,
9
9
  UpdateInfo,
10
- Updater,
11
10
  UpdateState,
12
11
  )
12
+ from synodic_client.updater import Updater
13
13
 
14
14
  try:
15
15
  __version__ = importlib.metadata.version('synodic_client')
@@ -17,10 +17,9 @@ import sys
17
17
 
18
18
  from synodic_client.config import set_dev_mode
19
19
  from synodic_client.logging import configure_logging
20
+ from synodic_client.protocol import extract_uri_from_args
20
21
  from synodic_client.updater import initialize_velopack
21
22
 
22
- _PROTOCOL_SCHEME = 'synodic'
23
-
24
23
  # Parse --dev flag early so logging uses the right filename.
25
24
  _dev_mode = '--dev' in sys.argv[1:]
26
25
  set_dev_mode(_dev_mode)
@@ -36,5 +35,4 @@ if not _dev_mode:
36
35
  # Heavy imports happen here — PySide6, porringer, etc.
37
36
  from synodic_client.application.qt import application
38
37
 
39
- _uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None)
40
- application(uri=_uri, dev_mode=_dev_mode)
38
+ application(uri=extract_uri_from_args(), dev_mode=_dev_mode)
@@ -14,7 +14,6 @@ from __future__ import annotations
14
14
 
15
15
  import asyncio
16
16
  import logging
17
- from dataclasses import dataclass, field
18
17
 
19
18
  from porringer.api import API
20
19
  from porringer.backend.command.core.discovery import DiscoveredPlugins
@@ -22,36 +21,11 @@ from porringer.core.plugin_schema.plugin_manager import PluginManager
22
21
  from porringer.schema import (
23
22
  CheckParameters,
24
23
  CheckResult,
25
- DirectoryValidationResult,
26
- ManifestDirectory,
27
- PluginInfo,
28
24
  )
29
25
 
30
- logger = logging.getLogger(__name__)
31
-
32
-
33
- @dataclass(slots=True)
34
- class Snapshot:
35
- """Immutable bundle of data produced by a single refresh cycle.
36
-
37
- All fields are populated by :meth:`DataCoordinator.refresh` and
38
- remain stable until the next refresh.
39
- """
26
+ from synodic_client.application.schema import Snapshot
40
27
 
41
- plugins: list[PluginInfo] = field(default_factory=list)
42
- """All discovered plugins with install status and version info."""
43
-
44
- directories: list[ManifestDirectory] = field(default_factory=list)
45
- """Cached project directories (un-validated)."""
46
-
47
- validated_directories: list[DirectoryValidationResult] = field(default_factory=list)
48
- """Cached directories with ``exists`` / ``has_manifest`` validation."""
49
-
50
- discovered: DiscoveredPlugins | None = None
51
- """Full plugin discovery result including runtime context."""
52
-
53
- plugin_managers: dict[str, PluginManager] = field(default_factory=dict)
54
- """Project-environment plugins implementing the ``PluginManager`` protocol."""
28
+ logger = logging.getLogger(__name__)
55
29
 
56
30
 
57
31
  class DataCoordinator:
@@ -23,7 +23,11 @@ from synodic_client.startup import register_startup, remove_startup
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
- _preamble_done = False
26
+
27
+ class _PreambleState:
28
+ """Module-level mutable state (avoids ``global`` statements)."""
29
+
30
+ done: bool = False
27
31
 
28
32
 
29
33
  def run_startup_preamble(exe_path: str | None = None) -> None:
@@ -39,10 +43,9 @@ def run_startup_preamble(exe_path: str | None = None) -> None:
39
43
  exe_path: Absolute path to the application executable. Defaults
40
44
  to ``sys.executable`` when not supplied.
41
45
  """
42
- global _preamble_done # noqa: PLW0603
43
- if _preamble_done:
46
+ if _PreambleState.done:
44
47
  return
45
- _preamble_done = True
48
+ _PreambleState.done = True
46
49
 
47
50
  if exe_path is None:
48
51
  exe_path = sys.executable
@@ -24,6 +24,7 @@ from synodic_client.application.uri import parse_uri
24
24
  from synodic_client.client import Client
25
25
  from synodic_client.config import set_dev_mode
26
26
  from synodic_client.logging import configure_logging
27
+ from synodic_client.protocol import extract_uri_from_args
27
28
  from synodic_client.resolution import (
28
29
  ResolvedConfig,
29
30
  resolve_config,
@@ -187,8 +188,5 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
187
188
  loop.run_forever()
188
189
 
189
190
 
190
- _PROTOCOL_SCHEME = 'synodic'
191
-
192
191
  if __name__ == '__main__':
193
- _uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None)
194
- application(uri=_uri)
192
+ application(uri=extract_uri_from_args())
@@ -0,0 +1,53 @@
1
+ """Application-layer data models.
2
+
3
+ Contains data structures shared across application modules — the
4
+ ``DataCoordinator`` snapshot and the tool-update result summary.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from porringer.backend.command.core.discovery import DiscoveredPlugins
12
+ from porringer.core.plugin_schema.plugin_manager import PluginManager
13
+ from porringer.schema import (
14
+ DirectoryValidationResult,
15
+ ManifestDirectory,
16
+ PluginInfo,
17
+ )
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class Snapshot:
22
+ """Immutable bundle of data produced by a single refresh cycle.
23
+
24
+ All fields are populated by :meth:`DataCoordinator.refresh` and
25
+ remain stable until the next refresh.
26
+ """
27
+
28
+ plugins: list[PluginInfo] = field(default_factory=list)
29
+ """All discovered plugins with install status and version info."""
30
+
31
+ directories: list[ManifestDirectory] = field(default_factory=list)
32
+ """Cached project directories (un-validated)."""
33
+
34
+ validated_directories: list[DirectoryValidationResult] = field(default_factory=list)
35
+ """Cached directories with ``exists`` / ``has_manifest`` validation."""
36
+
37
+ discovered: DiscoveredPlugins | None = None
38
+ """Full plugin discovery result including runtime context."""
39
+
40
+ plugin_managers: dict[str, PluginManager] = field(default_factory=dict)
41
+ """Project-environment plugins implementing the ``PluginManager`` protocol."""
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class ToolUpdateResult:
46
+ """Summary of a tool-update run across cached manifests."""
47
+
48
+ manifests_processed: int = 0
49
+ updated: int = 0
50
+ already_latest: int = 0
51
+ failed: int = 0
52
+ updated_packages: set[str] = field(default_factory=set)
53
+ """Package names that were successfully upgraded."""
@@ -6,9 +6,15 @@ execution log panel live here to avoid circular imports.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ from datetime import UTC, datetime
10
+
9
11
  from porringer.schema import SetupAction, SkipReason
10
12
  from porringer.schema.plugin import PluginKind
11
13
 
14
+ _SECONDS_PER_MINUTE = 60
15
+ _MINUTES_PER_HOUR = 60
16
+ _HOURS_PER_DAY = 24
17
+
12
18
  ACTION_KIND_LABELS: dict[PluginKind | None, str] = {
13
19
  PluginKind.PACKAGE: 'Package',
14
20
  PluginKind.TOOL: 'Tool',
@@ -57,15 +63,45 @@ def skip_reason_label(reason: SkipReason | None) -> str:
57
63
  return SKIP_REASON_LABELS.get(reason, reason.name.replace('_', ' ').capitalize())
58
64
 
59
65
 
60
- def format_cli_command(action: SetupAction) -> str:
66
+ def format_cli_command(action: SetupAction, *, suppress_description: bool = False) -> str:
61
67
  """Return a human-readable CLI command string for *action*.
62
68
 
63
69
  Prefers ``cli_command``, falls back to ``command``, then synthesises
64
70
  an ``installer install <package>`` string for package actions, and
65
71
  finally returns the action description as a last resort.
72
+
73
+ When *suppress_description* is ``True`` the final description
74
+ fallback returns an empty string instead.
66
75
  """
67
76
  if parts := (action.cli_command or action.command):
68
77
  return ' '.join(parts)
69
78
  if action.kind == PluginKind.PACKAGE and action.package:
70
79
  return f'{action.installer or "pip"} install {action.package}'
71
- return action.description
80
+ return '' if suppress_description else action.description
81
+
82
+
83
+ def _format_relative_time(iso_timestamp: str) -> str:
84
+ """Format an ISO 8601 timestamp as a human-readable relative time.
85
+
86
+ Returns strings like ``'just now'``, ``'5m ago'``, ``'2h ago'``,
87
+ ``'3d ago'``. Returns an empty string if the timestamp cannot be
88
+ parsed.
89
+ """
90
+ try:
91
+ dt = datetime.fromisoformat(iso_timestamp)
92
+ if dt.tzinfo is None:
93
+ dt = dt.replace(tzinfo=UTC)
94
+ delta = datetime.now(UTC) - dt
95
+ seconds = max(int(delta.total_seconds()), 0)
96
+ if seconds < _SECONDS_PER_MINUTE:
97
+ return 'just now'
98
+ minutes = seconds // _SECONDS_PER_MINUTE
99
+ if minutes < _MINUTES_PER_HOUR:
100
+ return f'{minutes}m ago'
101
+ hours = minutes // _MINUTES_PER_HOUR
102
+ if hours < _HOURS_PER_DAY:
103
+ return f'{hours}h ago'
104
+ days = hours // _HOURS_PER_DAY
105
+ return f'{days}d ago'
106
+ except ValueError, TypeError:
107
+ return ''
@@ -16,8 +16,8 @@ import logging
16
16
  from porringer.backend.command.core.action_builder import PHASE_ORDER
17
17
  from porringer.schema import SetupAction, SetupActionResult, SkipReason
18
18
  from porringer.schema.plugin import PluginKind
19
- from PySide6.QtCore import QRect, Qt, QTimer, Signal
20
- from PySide6.QtGui import QColor, QPainter, QPen
19
+ from PySide6.QtCore import Qt, QTimer, Signal
20
+ from PySide6.QtGui import QColor
21
21
  from PySide6.QtWidgets import (
22
22
  QApplication,
23
23
  QCheckBox,
@@ -30,6 +30,7 @@ from PySide6.QtWidgets import (
30
30
  )
31
31
 
32
32
  from synodic_client.application.screen import ACTION_KIND_LABELS, format_cli_command, skip_reason_label
33
+ from synodic_client.application.screen.spinner import SpinnerCanvas
33
34
  from synodic_client.application.theme import (
34
35
  ACTION_CARD_COMMAND_STYLE,
35
36
  ACTION_CARD_DESC_STYLE,
@@ -66,9 +67,6 @@ _UPDATE_AVAILABLE_COLOR = QColor('#d7ba7d')
66
67
  #: Timer interval for per-card inline spinner (ms).
67
68
  _SPINNER_INTERVAL = 50
68
69
 
69
- #: Arc span for per-card spinner (degrees × 16 for Qt drawArc).
70
- _SPINNER_ARC = 90
71
-
72
70
 
73
71
  #: Sort priority derived from porringer's execution phase order so the
74
72
  #: display order always matches the order actions actually execute.
@@ -101,62 +99,6 @@ def action_sort_key(action: SetupAction) -> int:
101
99
  return _KIND_ORDER.get(action.kind, len(PHASE_ORDER))
102
100
 
103
101
 
104
- def _format_command(action: SetupAction) -> str:
105
- """Return a short CLI command string for display.
106
-
107
- Wraps :func:`~synodic_client.application.screen.format_cli_command`
108
- but returns an empty string instead of the description fallback so
109
- cards only show an explicit command line.
110
- """
111
- text = format_cli_command(action)
112
- return '' if text == action.description else text
113
-
114
-
115
- # ---------------------------------------------------------------------------
116
- # _CardSpinner — tiny per-card inline spinner
117
- # ---------------------------------------------------------------------------
118
-
119
-
120
- class _CardSpinner(QWidget):
121
- """Tiny spinning arc used inside an :class:`ActionCard` while checking.
122
-
123
- The spinner replaces the status text label during the dry-run check
124
- phase and is hidden once the result arrives.
125
- """
126
-
127
- def __init__(self, parent: QWidget | None = None) -> None:
128
- super().__init__(parent)
129
- self._angle = 0
130
- self.setFixedSize(ACTION_CARD_SPINNER_SIZE, ACTION_CARD_SPINNER_SIZE)
131
-
132
- def paintEvent(self, _event: object) -> None:
133
- """Draw the muted track and animated highlight arc."""
134
- painter = QPainter(self)
135
- painter.setRenderHint(QPainter.RenderHint.Antialiasing)
136
-
137
- m = ACTION_CARD_SPINNER_PEN // 2 + 1
138
- rect = QRect(m, m, self.width() - 2 * m, self.height() - 2 * m)
139
-
140
- # Track circle
141
- track_pen = QPen(self.palette().mid(), ACTION_CARD_SPINNER_PEN)
142
- track_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
143
- painter.setPen(track_pen)
144
- painter.drawEllipse(rect)
145
-
146
- # Highlight arc
147
- hl_pen = QPen(self.palette().highlight(), ACTION_CARD_SPINNER_PEN)
148
- hl_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
149
- painter.setPen(hl_pen)
150
- painter.drawArc(rect, self._angle * 16, _SPINNER_ARC * 16)
151
-
152
- painter.end()
153
-
154
- def tick(self) -> None:
155
- """Advance the arc and repaint."""
156
- self._angle = (self._angle - 10) % 360
157
- self.update()
158
-
159
-
160
102
  # ---------------------------------------------------------------------------
161
103
  # ActionCard — a single action row
162
104
  # ---------------------------------------------------------------------------
@@ -297,7 +239,11 @@ class ActionCard(QFrame):
297
239
  top.addWidget(self._version_label)
298
240
 
299
241
  # Inline spinner (replaces status text while checking)
300
- self._spinner_canvas = _CardSpinner(self)
242
+ self._spinner_canvas = SpinnerCanvas(
243
+ size=ACTION_CARD_SPINNER_SIZE,
244
+ pen_width=ACTION_CARD_SPINNER_PEN,
245
+ parent=self,
246
+ )
301
247
  self._spinner_canvas.hide()
302
248
  self._spinner_timer = QTimer(self)
303
249
  self._spinner_timer.setInterval(_SPINNER_INTERVAL)
@@ -418,7 +364,7 @@ class ActionCard(QFrame):
418
364
  self._desc_label.hide()
419
365
 
420
366
  # CLI command (always visible when present)
421
- cmd_text = _format_command(action)
367
+ cmd_text = format_cli_command(action, suppress_description=True)
422
368
  if cmd_text:
423
369
  self._command_label.setText(cmd_text)
424
370
  self._command_row.show()
@@ -461,7 +407,7 @@ class ActionCard(QFrame):
461
407
  """
462
408
  if self._is_skeleton:
463
409
  return
464
- cmd_text = _format_command(action)
410
+ cmd_text = format_cli_command(action, suppress_description=True)
465
411
  if cmd_text:
466
412
  self._command_label.setText(cmd_text)
467
413
  self._command_row.show()