Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions synodic_client/application/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@

from synodic_client.config import set_dev_mode
from synodic_client.logging import configure_logging
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
from synodic_client.protocol import extract_uri_from_args
from synodic_client.updater import initialize_velopack

# Parse flags early so logging uses the right filename and level.
_dev_mode = '--dev' in sys.argv[1:]
_debug = '--debug' in sys.argv[1:]
set_dev_mode(_dev_mode)
_apply_subprocess_patch()

configure_logging(debug=_debug)
initialize_velopack()
Expand Down
31 changes: 31 additions & 0 deletions synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
resolve_config,
resolve_update_config,
)
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
from synodic_client.updater import initialize_velopack


Expand Down Expand Up @@ -72,6 +73,23 @@ def _process_uri(uri: str, handler: Callable[[str], None]) -> None:
handler(manifests[0])


def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None:
"""Cancel every pending asyncio task on *loop*.

Called synchronously from the ``aboutToQuit`` handler. Each task
receives a cancellation request; when the event loop processes its
remaining iterations the ``CancelledError`` propagates and the
tasks finish cleanly.
"""
_logger = logging.getLogger(__name__)
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
if not pending:
return
_logger.info('Cancelling %d pending async task(s)', len(pending))
for task in pending:
task.cancel()


def _install_exception_hook(logger: logging.Logger) -> None:
"""Redirect unhandled exceptions to the log file.

Expand Down Expand Up @@ -163,6 +181,7 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
"""
# Activate dev-mode namespacing before anything reads config paths.
set_dev_mode(dev_mode)
_apply_subprocess_patch()

# Configure logging before Velopack so install/uninstall hooks and
# first-run diagnostics are captured in the log file.
Expand Down Expand Up @@ -221,6 +240,18 @@ def _handle_install_uri(manifest_url: str) -> None:
if uri:
_process_uri(uri, _handle_install_uri)

# --- Graceful shutdown ---
# aboutToQuit fires synchronously when app.quit() is called but
# before the event loop stops, giving us a window to cancel
# in-flight async tasks and stop timers.

def _on_about_to_quit() -> None:
logger.info('Application shutting down — cancelling async tasks')
_tray.shutdown()
_cancel_all_tasks(loop)

app.aboutToQuit.connect(_on_about_to_quit)

# qasync integrates the asyncio event loop with Qt's event loop,
# enabling async/await usage in the GUI layer without dedicated threads.
with loop:
Expand Down
12 changes: 7 additions & 5 deletions synodic_client/application/screen/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from synodic_client.application.screen.install import SetupPreviewWidget
from synodic_client.application.screen.schema import PreviewPhase
from synodic_client.application.screen.sidebar import ManifestSidebar
from synodic_client.application.screen.spinner import SpinnerWidget
from synodic_client.application.screen.spinner import LoadingIndicator
from synodic_client.application.theme import COMPACT_MARGINS
from synodic_client.resolution import ResolvedConfig

Expand Down Expand Up @@ -91,9 +91,10 @@ def _init_ui(self) -> None:
self._empty_placeholder.setStyleSheet('color: grey; font-size: 13px;')
self._stack.addWidget(self._empty_placeholder)

outer.addLayout(right, stretch=1)
self._loading_indicator = LoadingIndicator('Loading projects\u2026')
self._stack.addWidget(self._loading_indicator)

self._loading_spinner = SpinnerWidget('Loading projects\u2026', parent=self)
outer.addLayout(right, stretch=1)

# --- Public API ---

Expand All @@ -106,7 +107,8 @@ def refresh(self) -> None:
async def _async_refresh(self) -> None:
"""Refresh the sidebar and stacked widgets from the porringer cache."""
self._refresh_in_progress = True
self._loading_spinner.start()
self._loading_indicator.start()
self._stack.setCurrentWidget(self._loading_indicator)
self._sidebar.set_enabled(False)

try:
Expand Down Expand Up @@ -167,7 +169,7 @@ async def _async_refresh(self) -> None:
except Exception:
logger.exception('Failed to refresh projects')
finally:
self._loading_spinner.stop()
self._loading_indicator.stop()
self._sidebar.set_enabled(True)
self._refresh_in_progress = False

Expand Down
23 changes: 15 additions & 8 deletions synodic_client/application/screen/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
ProjectInstance,
RefreshData,
)
from synodic_client.application.screen.spinner import SpinnerWidget
from synodic_client.application.screen.spinner import LoadingIndicator
from synodic_client.application.screen.update_banner import UpdateBanner
from synodic_client.application.theme import (
COMPACT_MARGINS,
Expand Down Expand Up @@ -197,7 +197,8 @@ def _init_ui(self) -> None:
self._scroll.setWidget(self._container)
outer.addWidget(self._scroll)

self._loading_spinner = SpinnerWidget('Loading tools\u2026', parent=self)
self._loading_indicator = LoadingIndicator('Loading tools\u2026')
outer.addWidget(self._loading_indicator)

# Periodic timer to refresh relative timestamps (every 60s)
self._timestamp_timer = QTimer(self)
Expand All @@ -224,10 +225,10 @@ def _build_toolbar(self) -> QHBoxLayout:
toolbar.addWidget(check_btn)
self._check_btn = check_btn

update_all_btn = QPushButton('Update All')
update_all_btn.setToolTip('Upgrade all auto-update-enabled plugins now')
update_all_btn.clicked.connect(self.update_all_requested.emit)
toolbar.addWidget(update_all_btn)
self._update_all_btn = QPushButton('Update All')
self._update_all_btn.setToolTip('Upgrade all auto-update-enabled plugins now')
self._update_all_btn.clicked.connect(self.update_all_requested.emit)
toolbar.addWidget(self._update_all_btn)

return toolbar

Expand All @@ -248,7 +249,10 @@ async def _async_refresh(self) -> None:
background task so the widget tree renders immediately.
"""
self._refresh_in_progress = True
self._loading_spinner.start()
self._scroll.hide()
self._loading_indicator.start()
self._check_btn.setEnabled(False)
self._update_all_btn.setEnabled(False)
need_deferred_check = False

try:
Expand All @@ -259,7 +263,10 @@ async def _async_refresh(self) -> None:
logger.exception('Failed to refresh tools')
need_deferred_check = False
finally:
self._loading_spinner.stop()
self._loading_indicator.stop()
self._scroll.show()
self._check_btn.setEnabled(True)
self._update_all_btn.setEnabled(True)
self._refresh_in_progress = False

# Fire-and-forget: detect updates in the background, then patch
Expand Down
82 changes: 46 additions & 36 deletions synodic_client/application/screen/spinner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@

Provides :class:`SpinnerCanvas` — a lightweight, palette-aware spinning
arc that can be sized and styled for any context — and
:class:`SpinnerWidget` — a self-positioning overlay variant with an
optional text label.
:class:`LoadingIndicator` — a centred spinner-plus-label widget suited
for embedding in layouts as a loading placeholder.

:class:`SpinnerCanvas` is used directly in plugin rows and action cards
where only a small inline indicator is needed. :class:`SpinnerWidget`
wraps a canvas and centres itself over its parent for modal-style use.
where only a small inline indicator is needed. :class:`LoadingIndicator`
wraps a canvas with an optional label and is designed to be placed into
a ``QStackedWidget`` page or swapped with content by the consumer.
"""

from __future__ import annotations

from PySide6.QtCore import QEvent, QRect, Qt, QTimer
from PySide6.QtCore import QRect, Qt, QTimer
from PySide6.QtGui import QPainter, QPen
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget

from synodic_client.application.theme import LOADING_LABEL_STYLE

_DEFAULT_SIZE = 24
_DEFAULT_PEN = 3
_INTERVAL = 50
Expand Down Expand Up @@ -83,23 +86,36 @@ def tick(self) -> None:
self.update()


class SpinnerWidget(QWidget):
"""Animated spinner circle with optional text label.
class LoadingIndicator(QWidget):
"""Centred spinner arc with an optional text label.

Designed to be placed into a layout — for example as a page in a
``QStackedWidget`` or shown/hidden alongside content. The widget
expands to fill available space and centres its contents.

The consumer is responsible for swapping visibility or stack pages;
this component manages only its own animation and display state.

Typical usage::

When a *parent* is provided the widget configures itself as a
floating overlay that fills the parent's geometry automatically.
No ``resizeEvent`` override, ``setSizePolicy``, ``raise_()``, or
``lower()`` call is needed by the consumer — just ``start()`` and
``stop()``.
indicator = LoadingIndicator('Loading…')
stack.addWidget(indicator)

# begin loading
indicator.start()
stack.setCurrentWidget(indicator)

# finish loading
indicator.stop()
stack.setCurrentWidget(content)
"""

def __init__(self, text: str = '', parent: QWidget | None = None) -> None:
"""Initialize the spinner.
"""Create a loading indicator.

Args:
text: Optional label shown beside the spinner arc.
parent: Optional parent widget. When set, the spinner
becomes a floating overlay that tracks the parent size.
parent: Optional parent widget.
"""
super().__init__(parent)
self.hide()
Expand All @@ -109,6 +125,8 @@ def __init__(self, text: str = '', parent: QWidget | None = None) -> None:
self._timer.setInterval(_INTERVAL)
self._timer.timeout.connect(self._canvas.tick)

self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)

outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.addStretch()
Expand All @@ -118,41 +136,33 @@ def __init__(self, text: str = '', parent: QWidget | None = None) -> None:
row.addStretch()
row.addWidget(self._canvas)
self._label = QLabel(text)
self._label.setStyleSheet(LOADING_LABEL_STYLE)
if text:
row.addWidget(self._label)
row.addStretch()

outer.addLayout(row)
outer.addStretch()

# Auto-overlay: track parent geometry via event filter
if parent is not None:
self.setAutoFillBackground(True)
self.setStyleSheet('background: palette(window);')
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
parent.installEventFilter(self)
self.setGeometry(parent.rect())

# -- Event filter (overlay geometry tracking) --------------------------
# -- Public API --------------------------------------------------------

def eventFilter(self, obj: object, event: QEvent) -> bool:
"""Resize to match the parent whenever it resizes."""
parent = self.parent()
if event.type() == QEvent.Type.Resize and obj is parent and isinstance(parent, QWidget):
self.setGeometry(parent.rect())
return False
@property
def running(self) -> bool:
"""Return ``True`` if the animation is currently active."""
return self._timer.isActive()

# -- Public API --------------------------------------------------------
def set_text(self, text: str) -> None:
"""Update the label text."""
self._label.setText(text)
self._label.setVisible(bool(text))

def start(self) -> None:
"""Show the overlay and start the animation."""
self.raise_()
self.show()
"""Reset the arc angle, start the animation, and show the widget."""
self._canvas._angle = 0
self._timer.start()
self.show()

def stop(self) -> None:
"""Stop the animation, hide, and move below siblings."""
"""Stop the animation and hide the widget."""
self._timer.stop()
self.hide()
self.lower()
28 changes: 28 additions & 0 deletions synodic_client/application/screen/tool_update_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ def __init__(
self._tool_task: asyncio.Task[None] | None = None
self._tool_update_timer: QTimer | None = None

def shutdown(self) -> None:
"""Stop timers and cancel in-flight tasks for a clean exit."""
if self._tool_update_timer is not None:
self._tool_update_timer.stop()
self._tool_update_timer = None
if self._tool_task is not None and not self._tool_task.done():
self._tool_task.cancel()
self._tool_task = None
logger.info('ToolUpdateOrchestrator shut down')

# -- Timer management --

@staticmethod
Expand Down Expand Up @@ -175,6 +185,9 @@ async def _do_tool_update(self, porringer: API) -> None:
if coordinator is not None:
coordinator.invalidate()
self._on_tool_update_finished(result)
except asyncio.CancelledError:
logger.debug('Tool update cancelled (shutdown)')
raise
except Exception as exc:
logger.exception('Tool update failed')
self._on_tool_update_error(str(exc))
Expand Down Expand Up @@ -238,6 +251,9 @@ async def _async_runtime_plugin_update(
if coordinator is not None:
coordinator.invalidate()
self._on_tool_update_finished(result, updating_plugin=signal_key, manual=True)
except asyncio.CancelledError:
logger.debug('Runtime plugin update cancelled (shutdown)')
raise
except Exception as exc:
logger.exception('Runtime tool update failed')
tools_view = self._window.tools_view
Expand Down Expand Up @@ -270,6 +286,9 @@ async def _async_single_plugin_update(self, porringer: API, plugin_name: str) ->
if coordinator is not None:
coordinator.invalidate()
self._on_tool_update_finished(result, updating_plugin=plugin_name, manual=True)
except asyncio.CancelledError:
logger.debug('Single plugin update cancelled (shutdown)')
raise
except Exception as exc:
logger.exception('Tool update failed')
tools_view = self._window.tools_view
Expand Down Expand Up @@ -326,6 +345,9 @@ async def _async_single_package_update(
updating_package=(plugin_name, package_name),
manual=True,
)
except asyncio.CancelledError:
logger.debug('Runtime package update cancelled (shutdown)')
raise
except Exception as exc:
logger.exception('Runtime package update failed')
tools_view = self._window.tools_view
Expand All @@ -348,6 +370,9 @@ async def _async_single_package_update(
updating_package=(plugin_name, package_name),
manual=True,
)
except asyncio.CancelledError:
logger.debug('Package update cancelled (shutdown)')
raise
except Exception as exc:
logger.exception('Package update failed')
tools_view = self._window.tools_view
Expand Down Expand Up @@ -470,6 +495,9 @@ async def _async_single_package_remove(
if coordinator is not None:
coordinator.invalidate()
self._on_package_remove_finished(result, plugin_name, package_name)
except asyncio.CancelledError:
logger.debug('Package removal cancelled (shutdown)')
raise
except Exception as exc:
logger.exception('Package removal failed')
tools_view = self._window.tools_view
Expand Down
Loading
Loading