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
37 changes: 35 additions & 2 deletions synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
import logging
import signal
import sys
import traceback
import types
from collections.abc import Callable

import qasync
from porringer.api import API
from porringer.schema import LocalConfiguration
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QEvent, QObject, Qt, QTimer
from PySide6.QtWidgets import QApplication, QWidget

from synodic_client.application.icon import app_icon
from synodic_client.application.init import run_startup_preamble
Expand Down Expand Up @@ -90,6 +91,34 @@ def _exception_hook(
sys.excepthook = _exception_hook


class _TopLevelShowFilter(QObject):
"""[DIAG] Application-wide event filter that logs Show/WindowActivate on top-level widgets."""

_diag_logger = logging.getLogger('synodic_client.diag.window')

def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802
if (
event.type() in {QEvent.Type.Show, QEvent.Type.WindowActivate}
and isinstance(obj, QWidget)
and obj.isWindow()
):
geo = obj.geometry()
stack = ''.join(traceback.format_stack(limit=12))
self._diag_logger.warning(
'[DIAG] Top-level window %s: class=%s title=%r geo=(%d,%d %dx%d) visible=%s\n%s',
event.type().name,
type(obj).__qualname__,
obj.windowTitle(),
geo.x(),
geo.y(),
geo.width(),
geo.height(),
obj.isVisible(),
stack,
)
return False


def _init_app() -> QApplication:
"""Create and configure the ``QApplication``."""
# Set the App User Model ID so Windows uses our icon on the taskbar
Expand All @@ -104,6 +133,10 @@ def _init_app() -> QApplication:
app.setWindowIcon(app_icon())
app.setAttribute(Qt.ApplicationAttribute.AA_CompressHighFrequencyEvents)

# [DIAG] Install a global event filter to log every top-level window show.
diag_filter = _TopLevelShowFilter(app) # parented to app, prevented from GC
app.installEventFilter(diag_filter)

# Allow Ctrl+C in the terminal to terminate the application.
# Qt's event loop blocks Python's default SIGINT handling, so we
# install our own handler and use a short timer to let Python
Expand Down
22 changes: 17 additions & 5 deletions synodic_client/application/screen/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import asyncio
import logging
import traceback
from pathlib import Path
from typing import Any

Expand All @@ -27,6 +28,7 @@
SyncStrategy,
)
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import (
QFileDialog,
QFrame,
Expand Down Expand Up @@ -890,6 +892,21 @@ def __init__(

self._init_ui()

def showEvent(self, event: QShowEvent) -> None: # noqa: N802
"""[DIAG] Log every show event with a stack trace."""
geo = self.geometry()
stack = ''.join(traceback.format_stack(limit=10))
logger.warning(
'[DIAG] InstallPreviewWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
geo.x(),
geo.y(),
geo.width(),
geo.height(),
self.isVisible(),
stack,
)
super().showEvent(event)

def _init_ui(self) -> None:
"""Build the UI layout."""
central = QWidget()
Expand Down Expand Up @@ -952,11 +969,6 @@ def _on_browse_project_dir(self) -> None:

# --- Lifecycle ---

def showEvent(self, event: Any) -> None:
"""Log when the window becomes visible."""
super().showEvent(event)
logger.info('Install preview window shown (visible=%s)', self.isVisible())

def closeEvent(self, event: Any) -> None:
"""Clean up the temp directory when the window is closed."""
logger.info('Install preview window closing')
Expand Down
40 changes: 28 additions & 12 deletions synodic_client/application/screen/plugin_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
PLUGIN_ROW_PROJECT_TAG_STYLE,
PLUGIN_ROW_PROJECT_TAG_TRANSITIVE_STYLE,
PLUGIN_ROW_REMOVE_STYLE,
PLUGIN_ROW_STATUS_MIN_WIDTH,
PLUGIN_ROW_STATUS_STYLE,
PLUGIN_ROW_STYLE,
PLUGIN_ROW_TIMESTAMP_MIN_WIDTH,
PLUGIN_ROW_TIMESTAMP_STYLE,
PLUGIN_ROW_TOGGLE_STYLE,
PLUGIN_ROW_UPDATE_STYLE,
Expand Down Expand Up @@ -341,7 +343,7 @@ def _build_controls(self, layout: QHBoxLayout, data: PluginRowData) -> None:

Controls are always created in the same order with fixed widths
so that columns align vertically across all rows. Hidden
controls still reserve space.
controls reserve space via ``retainSizeWhenHidden``.
"""
if data.show_toggle:
self._build_toggle(layout, data)
Expand All @@ -352,23 +354,25 @@ def _build_controls(self, layout: QHBoxLayout, data: PluginRowData) -> None:
# Inline auto-update status (e.g. "Up to date", "v1.2 available")
self._update_status_label = QLabel()
self._update_status_label.setStyleSheet(PLUGIN_ROW_STATUS_STYLE)
self._update_status_label.setMinimumWidth(PLUGIN_ROW_STATUS_MIN_WIDTH)
self._update_status_label.hide()
layout.addWidget(self._update_status_label)
self._retain_size(layout)

# Version
if data.version:
version_label = QLabel(data.version)
version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE)
version_label.setMinimumWidth(PLUGIN_ROW_VERSION_MIN_WIDTH)
version_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(version_label)
# Version — always created so column width is reserved
version_label = QLabel(data.version)
version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE)
version_label.setMinimumWidth(PLUGIN_ROW_VERSION_MIN_WIDTH)
version_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(version_label)

# Timestamp
# Timestamp — always created so column width is reserved
self._timestamp_label = QLabel(_format_relative_time(data.last_updated) if data.last_updated else '')
self._timestamp_label.setStyleSheet(PLUGIN_ROW_TIMESTAMP_STYLE)
self._timestamp_label.setMinimumWidth(PLUGIN_ROW_TIMESTAMP_MIN_WIDTH)
if data.last_updated:
self._timestamp_label = QLabel(_format_relative_time(data.last_updated))
self._timestamp_label.setStyleSheet(PLUGIN_ROW_TIMESTAMP_STYLE)
self._timestamp_label.setToolTip(f'Last updated: {data.last_updated}')
layout.addWidget(self._timestamp_label)
layout.addWidget(self._timestamp_label)

# Transient inline error label (hidden by default)
self._error_label = QLabel()
Expand Down Expand Up @@ -409,6 +413,18 @@ def _build_update_button(self, layout: QHBoxLayout, data: PluginRowData) -> None
update_btn.setVisible(data.has_update)
self._update_btn = update_btn
layout.addWidget(update_btn)
self._retain_size(layout)

@staticmethod
def _retain_size(layout: QHBoxLayout) -> None:
"""Mark the most recently added widget as size-retaining when hidden."""
item = layout.itemAt(layout.count() - 1)
if item is not None:
widget = item.widget()
if widget is not None:
policy = widget.sizePolicy()
policy.setRetainSizeWhenHidden(True)
widget.setSizePolicy(policy)

def _build_remove_button(self, layout: QHBoxLayout, data: PluginRowData) -> None:
"""Add the remove button — enabled only for global packages."""
Expand Down
19 changes: 19 additions & 0 deletions synodic_client/application/screen/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import logging
import traceback
from collections import OrderedDict
from pathlib import Path

Expand All @@ -20,6 +21,7 @@
)
from porringer.schema.plugin import PluginKind
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QShowEvent
from PySide6.QtWidgets import (
QHBoxLayout,
QLineEdit,
Expand Down Expand Up @@ -197,6 +199,8 @@ def refresh(self) -> None:
"""Schedule an asynchronous rebuild of the tool list."""
if self._refresh_in_progress:
return
caller = ''.join(traceback.format_stack(limit=4))
logger.info('[DIAG] ToolsView.refresh() called, parent_visible=%s\n%s', self.isVisible(), caller)
asyncio.create_task(self._async_refresh())

async def _async_refresh(self) -> None:
Expand Down Expand Up @@ -1104,6 +1108,21 @@ def __init__(
# Update banner — always available, starts hidden.
self._update_banner = UpdateBanner(self)

def showEvent(self, event: QShowEvent) -> None: # noqa: N802
"""[DIAG] Log every show event with a stack trace."""
geo = self.geometry()
stack = ''.join(traceback.format_stack(limit=10))
logger.warning(
'[DIAG] MainWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
geo.x(),
geo.y(),
geo.width(),
geo.height(),
self.isVisible(),
stack,
)
super().showEvent(event)

@property
def porringer(self) -> API | None:
"""Return the porringer API instance, if available."""
Expand Down
18 changes: 17 additions & 1 deletion synodic_client/application/screen/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@

import logging
import sys
import traceback
from collections.abc import Iterator
from contextlib import contextmanager

from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtGui import QDesktopServices, QShowEvent
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
Expand Down Expand Up @@ -53,6 +54,21 @@ class SettingsWindow(QMainWindow):
check_updates_requested = Signal()
"""Emitted when the user clicks the *Check for Updates* button."""

def showEvent(self, event: QShowEvent) -> None: # noqa: N802
"""[DIAG] Log every show event with a stack trace."""
geo = self.geometry()
stack = ''.join(traceback.format_stack(limit=10))
logger.warning(
'[DIAG] SettingsWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
geo.x(),
geo.y(),
geo.width(),
geo.height(),
self.isVisible(),
stack,
)
super().showEvent(event)

def __init__(
self,
config: ResolvedConfig,
Expand Down
20 changes: 19 additions & 1 deletion synodic_client/application/screen/tool_update_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,22 @@ def __init__(
window: MainWindow,
config_resolver: Callable[[], ResolvedConfig],
tray: QSystemTrayIcon,
is_user_active: Callable[[], bool] | None = None,
) -> None:
"""Set up the controller.

Args:
window: The main application window.
config_resolver: Callable returning the current resolved config.
tray: System tray icon for notification messages.
is_user_active: Predicate returning ``True`` when the user
has a visible window. Periodic tool updates are
deferred while active.
"""
self._window = window
self._resolve_config = config_resolver
self._tray = tray
self._is_user_active = is_user_active or (lambda: False)
self._tool_task: asyncio.Task[None] | None = None
self._tool_update_timer: QTimer | None = None

Expand Down Expand Up @@ -108,10 +113,17 @@ def restart_tool_update_timer(self) -> None:
self._tool_update_timer = self._restart_timer(
self._tool_update_timer,
config.tool_update_interval_minutes,
self.on_tool_update,
self._on_periodic_tool_update,
'Automatic tool updating',
)

def _on_periodic_tool_update(self) -> None:
"""Timer callback — deferred when the user has a visible window."""
if self._is_user_active():
logger.debug('Periodic tool update deferred — user is active')
return
self.on_tool_update()

# -- ToolsView signal wiring --

def connect_tools_view(self, tools_view: ToolsView) -> None:
Expand Down Expand Up @@ -293,6 +305,12 @@ def _on_tool_update_finished(

# Clear updating state on widgets
tools_view = self._window.tools_view
logger.info(
'[DIAG] _on_tool_update_finished: manual=%s, tools_view_exists=%s, window_visible=%s',
manual,
tools_view is not None,
self._window.isVisible(),
)
if tools_view is not None:
if updating_plugin is not None:
tools_view.set_plugin_updating(updating_plugin, False)
Expand Down
14 changes: 12 additions & 2 deletions synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,17 @@ def __init__(
app,
client,
self._banner,
self._settings_window,
config,
settings_window=self._settings_window,
config=config,
)
self._update_controller.set_user_active_predicate(self._is_user_active)

# Tool update orchestrator - owns tool/package update lifecycle
self._tool_orchestrator = ToolUpdateOrchestrator(
window,
self._resolve_config,
self.tray,
is_user_active=self._is_user_active,
)
self._tool_orchestrator.restart_tool_update_timer()

Expand Down Expand Up @@ -128,6 +130,14 @@ def _show_settings(self) -> None:
"""Show the settings window."""
self._settings_window.show()

def _is_user_active(self) -> bool:
"""Return ``True`` when the user has a visible window.

Used by the update controllers to defer automatic updates
while the user is actively interacting with the application.
"""
return self._window.isVisible() or self._settings_window.isVisible()

def _on_settings_changed(self, config: ResolvedConfig) -> None:
"""React to a change made in the settings window."""
self._config = config
Expand Down
6 changes: 6 additions & 0 deletions synodic_client/application/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@
PLUGIN_ROW_VERSION_MIN_WIDTH = 60
"""Minimum width for the version label column."""

PLUGIN_ROW_STATUS_MIN_WIDTH = 90
"""Minimum width for the inline auto-update status label."""

PLUGIN_ROW_TIMESTAMP_MIN_WIDTH = 40
"""Minimum width for the relative timestamp label."""

PLUGIN_ROW_SPACING = 1
"""Pixels between individual tool/package rows."""

Expand Down
Loading