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
61 changes: 0 additions & 61 deletions synodic_client/application/screen/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@
import enum
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from typing import Protocol, runtime_checkable

from porringer.schema import (
PluginInfo,
Expand Down Expand Up @@ -343,62 +341,3 @@ class _DispatchState:

action_index: dict[int, int] = field(default_factory=dict)
got_parsed: bool = False


# ---------------------------------------------------------------------------
# Update view protocol & banner data models
# ---------------------------------------------------------------------------


@runtime_checkable
class UpdateView(Protocol):
"""Minimal display contract for the self-update lifecycle.

:class:`UpdateBanner` satisfies this protocol implicitly via
structural typing. The controller broadcasts state transitions
through a ``list[UpdateView]`` so that every window showing update
status stays in sync.
"""

def show_downloading(self, version: str) -> None:
"""Indicate that *version* is being downloaded."""
...

def show_downloading_progress(self, percentage: int) -> None:
"""Update the download progress indicator."""
...

def show_ready(self, version: str) -> None:
"""Indicate that *version* is downloaded and ready to install."""
...

def show_error(self, message: str) -> None:
"""Display an error *message* in the update area."""
...

def hide_banner(self) -> None:
"""Hide the update banner."""
...


class UpdateBannerState(Enum):
"""Visual states for the update banner."""

HIDDEN = auto()
DOWNLOADING = auto()
READY = auto()
ERROR = auto()


@dataclass(frozen=True, slots=True)
class _BannerConfig:
"""Bundled visual configuration for a banner state transition."""

state: UpdateBannerState
style: str
icon: str
text: str
text_style: str
version: str = ''
action_label: str = ''
show_progress: bool = False
62 changes: 28 additions & 34 deletions synodic_client/application/screen/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
from synodic_client.application.icon import app_icon
from synodic_client.application.screen import _format_relative_time
from synodic_client.application.screen.card import CardFrame
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
from synodic_client.application.update_model import UpdateModel
from synodic_client.logging import log_path, set_debug_level
from synodic_client.schema import GITHUB_REPO_URL
from synodic_client.startup import is_startup_registered, register_startup, remove_startup
Expand Down Expand Up @@ -237,6 +238,32 @@ def _build_advanced_section(self) -> CardFrame:
card.content_layout.addLayout(row)
return card

# ------------------------------------------------------------------
# Model binding
# ------------------------------------------------------------------

def connect_model(self, model: UpdateModel) -> None:
"""Connect to an :class:`UpdateModel` for state observation.

The model's settings-facing signals drive the update status
label, check button, restart button, and timestamp label.
"""
model.status_text_changed.connect(self._on_status_changed)
model.check_button_enabled_changed.connect(self._check_updates_btn.setEnabled)
model.restart_visible_changed.connect(self._restart_btn.setVisible)
model.last_checked_changed.connect(self._on_last_checked_changed)

def _on_status_changed(self, text: str, style: str) -> None:
"""Apply a status text and style from the model."""
self._update_status_label.setText(text)
self._update_status_label.setStyleSheet(style)

def _on_last_checked_changed(self, timestamp: str) -> None:
"""Apply a *last updated* timestamp from the model."""
relative = _format_relative_time(timestamp)
self._last_client_update_label.setText(f'Last updated: {relative}')
self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
Expand Down Expand Up @@ -275,37 +302,6 @@ def sync_from_config(self) -> None:
else:
self._last_client_update_label.setText('')

def set_update_status(self, text: str, style: str = '') -> None:
"""Set the inline status text next to the *Check for Updates* button.

Args:
text: The status message.
style: Optional stylesheet for the label (e.g. color).
"""
self._update_status_label.setText(text)
self._update_status_label.setStyleSheet(style)

def set_checking(self) -> None:
"""Enter the *checking* state — disable button and show status."""
self._check_updates_btn.setEnabled(False)
self._restart_btn.hide()
self._update_status_label.setText('Checking\u2026')
self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)

def reset_check_updates_button(self) -> None:
"""Re-enable the *Check for Updates* button after a check completes."""
self._check_updates_btn.setEnabled(True)

def set_last_checked(self, timestamp: str) -> None:
"""Update the *last updated* label from an ISO 8601 timestamp."""
relative = _format_relative_time(timestamp)
self._last_client_update_label.setText(f'Last updated: {relative}')
self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')

def show_restart_button(self) -> None:
"""Show the *Restart & Update* button."""
self._restart_btn.show()

def show(self) -> None:
"""Sync controls from config, size to content, then show the window."""
self.sync_from_config()
Expand Down Expand Up @@ -360,8 +356,6 @@ def _block_signals(self) -> Iterator[None]:

def _on_check_updates_clicked(self) -> None:
"""Handle the *Check for Updates* button click."""
self._check_updates_btn.setEnabled(False)
self._update_status_label.setText('Checking\u2026')
self.check_updates_requested.emit()

def _on_channel_changed(self, index: int) -> None:
Expand Down
17 changes: 15 additions & 2 deletions synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from synodic_client.application.screen.settings import SettingsWindow
from synodic_client.application.screen.tool_update_controller import ToolUpdateOrchestrator
from synodic_client.application.update_controller import UpdateController
from synodic_client.application.update_model import UpdateModel
from synodic_client.client import Client

if TYPE_CHECKING:
Expand Down Expand Up @@ -66,17 +67,29 @@ def __init__(
# MainWindow gear button -> open settings
window.settings_requested.connect(self._show_settings)

# Update model — centralised observable state for the update lifecycle
self._update_model = UpdateModel()

# Update controller - owns the self-update lifecycle & timer
self._banner = window.update_banner
self._update_controller = UpdateController(
app,
client,
[self._banner],
settings_window=self._settings_window,
self._update_model,
store=self._store,
)
self._update_controller.set_user_active_predicate(self._is_user_active)

# Connect views to the model
self._banner.connect_model(self._update_model)
self._settings_window.connect_model(self._update_model)

# Wire user-action signals back to the controller
self._banner.restart_requested.connect(self._update_controller.request_apply)
self._banner.retry_requested.connect(self._update_controller.request_retry)
self._settings_window.check_updates_requested.connect(self._update_controller.request_check)
self._settings_window.restart_requested.connect(self._update_controller.request_apply)

# Tool update orchestrator - owns tool/package update lifecycle
self._tool_orchestrator = ToolUpdateOrchestrator(
window,
Expand Down
66 changes: 64 additions & 2 deletions synodic_client/application/screen/update_banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from __future__ import annotations

import logging
from dataclasses import dataclass
from enum import Enum, auto

from PySide6.QtCore import (
QEasingCurve,
Expand All @@ -34,7 +36,6 @@
QWidget,
)

from synodic_client.application.screen.schema import UpdateBannerState, _BannerConfig
from synodic_client.application.theme import (
UPDATE_BANNER_ANIMATION_MS,
UPDATE_BANNER_BTN_STYLE,
Expand All @@ -47,10 +48,34 @@
UPDATE_BANNER_STYLE,
UPDATE_BANNER_VERSION_STYLE,
)
from synodic_client.application.update_model import UpdateModel, UpdatePhase

logger = logging.getLogger(__name__)


class UpdateBannerState(Enum):
"""Visual states for the update banner."""

HIDDEN = auto()
DOWNLOADING = auto()
READY = auto()
ERROR = auto()


@dataclass(frozen=True, slots=True)
class _BannerConfig:
"""Bundled visual configuration for a banner state transition."""

state: UpdateBannerState
style: str
icon: str
text: str
text_style: str
version: str = ''
action_label: str = ''
show_progress: bool = False


# Height of the banner content (progress variant is slightly taller).
_BANNER_HEIGHT = 38
_BANNER_HEIGHT_WITH_PROGRESS = 44
Expand All @@ -77,6 +102,7 @@ def __init__(self, parent: QWidget | None = None) -> None:

self._state = UpdateBannerState.HIDDEN
self._target_version: str = ''
self._error_dismiss_timer: QTimer | None = None

# --- Layout ---
self._outer = QVBoxLayout(self)
Expand Down Expand Up @@ -129,6 +155,28 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._anim.setEasingCurve(QEasingCurve.Type.OutCubic)
self._anim.setDuration(UPDATE_BANNER_ANIMATION_MS)

# --- Model binding ---

def connect_model(self, model: UpdateModel) -> None:
"""Connect to an :class:`UpdateModel` for state observation.

The model's lifecycle signals drive the banner's visual state
transitions so the controller never needs to call banner
methods directly.
"""
self._model = model
model.phase_changed.connect(self._on_model_phase)
model.progress_changed.connect(self.show_downloading_progress)

def _on_model_phase(self, phase: UpdatePhase) -> None:
"""React to a lifecycle phase change from the model."""
if phase == UpdatePhase.DOWNLOADING:
self.show_downloading(self._model.version)
elif phase == UpdatePhase.READY:
self.show_ready(self._model.version)
elif phase == UpdatePhase.ERROR:
self.show_error(self._model.error_message)

# --- Public API ---

@property
Expand Down Expand Up @@ -191,6 +239,12 @@ def show_error(self, message: str) -> None:
Args:
message: Human-readable error description.
"""
# Cancel any pending auto-dismiss from a previous error to avoid
# stacking timers that would forcibly hide a freshly shown banner.
if self._error_dismiss_timer is not None:
self._error_dismiss_timer.stop()
self._error_dismiss_timer = None

self._configure(
_BannerConfig(
state=UpdateBannerState.ERROR,
Expand All @@ -201,12 +255,20 @@ def show_error(self, message: str) -> None:
action_label='Retry',
)
)
QTimer.singleShot(UPDATE_BANNER_ERROR_DISMISS_MS, self._auto_dismiss_error)
timer = QTimer(self)
timer.setSingleShot(True)
timer.setInterval(UPDATE_BANNER_ERROR_DISMISS_MS)
timer.timeout.connect(self._auto_dismiss_error)
timer.start()
self._error_dismiss_timer = timer

def hide_banner(self) -> None:
"""Slide the banner out and reset to hidden."""
if self._state == UpdateBannerState.HIDDEN:
return
if self._error_dismiss_timer is not None:
self._error_dismiss_timer.stop()
self._error_dismiss_timer = None
self._state = UpdateBannerState.HIDDEN
self._animate_height(0)

Expand Down
Loading
Loading