# Copyright 2025–2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Tool API module for RadioViz application.
This module provides the core API for implementing tools within the RadioViz application.
It defines the base classes and interfaces that tools can use to interact with the application
environment, manage sessions, and access various services such as window management,
overlay rendering, and color handling.
The main components include:
- BaseToolSession for managing tool execution lifecycle
- ToolContext for providing application-facing APIs to tools
"""
from __future__ import annotations
from abc import abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeVar, Union
from uuid import UUID, uuid4
from PySide6.QtCore import QObject, Signal
from radioviz.controllers.image_window_controller import ImageWindowController
from radioviz.controllers.sub_window_controller import T_SubWindowController
from radioviz.models.overlays import OverlayRole, RendererFn
from radioviz.services.application_services import ApplicationServices
from radioviz.services.color_service import ColorService
from radioviz.services.window_factory import SubWindowProtocol
from radioviz.typing.tool_types import ToolSessionProtocol
if TYPE_CHECKING:
from radioviz.controllers.sub_window_controller import SubWindowController
from radioviz.tools.base_controller import ToolController
T_Tool_Controller = TypeVar('T_Tool_Controller', bound='ToolController[Any, Any]')
T_Session = TypeVar('T_Session', bound='BaseToolSession[Any, Any]')
[docs]
class WorkspaceRestoreContext(QObject):
"""
Context manager for tracking tool sessions during workspace restoration.
This class coordinates the restoration of multiple tool sessions by tracking
their completion status. It ensures that a restoration process is only considered
complete when all registered sessions have finished executing.
It also acts as a container for running sessions avoiding a session to be garbage collected
when its variable goes out of scope.
:ivar finished: Signal emitted when all tracked sessions have completed
"""
finished = Signal()
def __init__(self) -> None:
"""
Initialize the workspace restore context.
"""
super().__init__()
self._pending = 0
self._sessions: dict[UUID, ToolSessionProtocol] = {}
[docs]
def register_session(self, session: ToolSessionProtocol) -> None:
"""
Register a new tool session for tracking.
This method adds a session to the tracking list and increments the pending
count. The session will be monitored until it completes.
:param session: The tool session to register
:type session: ToolSessionProtocol
"""
self._pending += 1
self._sessions[session.session_id] = session
[docs]
def _on_session_finished(self, session_id: UUID) -> None:
"""
Internal handler for session completion events.
This method removes a completed session from tracking and decrements the
pending count. When all sessions are complete, it emits the finished signal.
:param session_id: The unique identifier of the completed session
:type session_id: UUID
"""
if session_id in self._sessions:
del self._sessions[session_id]
self._pending -= 1
if self._pending == 0:
self.finished.emit()
[docs]
class ToolContext(QObject):
"""
Application-facing API exposed to tools.
This class provides a comprehensive API for tools to interact with the
RadioViz application environment. It offers access to window management,
overlay systems, color services, and other application-level features.
:ivar message_requested: Signal emitted when a message needs to be shown
"""
message_requested = Signal(str, str, int) # text, level, timeout
def __init__(self) -> None:
"""
Initialize the tool context with application services.
"""
super().__init__()
self.application_services = ApplicationServices()
self._window_manager = self.application_services.window_manager
self._window_factory = self.application_services.window_factory
self._overlay_factory = self.application_services.overlay_factory
self._color_service = self.application_services.color_service
self._workspace_reference_manger = self.application_services.workspace_reference_manager
self._tool_controllers: list['ToolController[Any, Any]'] = []
self._active_tool_controller: ToolController[Any, Any] | None = None
# forward window-manager signal
self._window_manager.active_changed.connect(self._on_active_changed)
self._active_callbacks: list[Callable[[SubWindowController[Any] | None], None]] = []
@property
def active_tool_controller(self) -> ToolController[Any, Any] | None:
"""
Return the currently active tool controller.
:return: Active tool controller or ``None`` when no tool is active.
:rtype: ToolController | None
"""
return self._active_tool_controller
[docs]
def request_tool_activation(self, controller: 'ToolController[Any, Any]') -> None:
"""
Request activation of the given tool controller.
If another tool is active, it is deactivated before the new controller
becomes active. This enforces a single active tool at any time.
:param controller: Tool controller requesting activation.
:type controller: ToolController
"""
if self._active_tool_controller is not None:
self._active_tool_controller.deactivate()
self._active_tool_controller = controller
[docs]
def clear_active_tool(self, controller: 'ToolController[Any, Any]') -> None:
"""
Clear the active tool if it matches the given controller.
:param controller: Tool controller to clear.
:type controller: ToolController
"""
if self._active_tool_controller is controller:
self._active_tool_controller = None
[docs]
def activate_image(self, window_controller: 'SubWindowController[Any]') -> None:
"""
Activate a specific image window.
:param window_controller: The window controller to activate
:type window_controller: SubWindowController[Any]
"""
self._window_manager.active_window = window_controller
[docs]
def active_image(self) -> Union['SubWindowController[Any]', None]:
"""
Get the currently active window controller.
:return: The active window controller or None if no window is active
:rtype: Union[SubWindowController[Any], None]
"""
return self._window_manager.active_window
[docs]
def on_active_image_changed(self, callback: Callable[[SubWindowController[Any] | None], None]) -> None:
"""
Register a callback for active image change events.
:param callback: Function to call when active image changes
:type callback: Callable
"""
self._active_callbacks.append(callback)
[docs]
def _on_active_changed(self, image_controller: 'SubWindowController[Any] | None') -> None:
"""
Internal handler for active window changes.
:param image_controller: The new active window controller
:type image_controller: SubWindowController[Any]
"""
for cb in self._active_callbacks:
cb(image_controller)
[docs]
def all_images(self) -> list['SubWindowController[Any]']:
"""
Get a list of all available image windows.
:return: List of all image window controllers
:rtype: list[SubWindowController[Any]]
"""
return list(self._window_manager.window_list)
[docs]
def on_list_changed(self, callback: Callable[[], None]) -> None:
"""
Register a callback for window list change events.
:param callback: Function to call when window list changes
:type callback: Callable
"""
self._window_manager.list_changed.connect(callback)
[docs]
def request_redraw(self) -> None:
"""
Request a redraw of the active image window.
This method triggers a canvas update on the currently active image window.
"""
image = self.active_image()
if isinstance(image, ImageWindowController):
image.request_canvas_update.emit()
[docs]
def show_message(self, text: str, level: Literal['info', 'warning', 'error'], timeout_ms: int = 3000) -> None:
"""
Show a message in the application UI.
:param text: The message text to display
:type text: str
:param level: The severity level of the message
:type level: Literal['info', 'warning', 'error']
:param timeout_ms: Timeout in milliseconds before message disappears
:type timeout_ms: int
"""
self.message_requested.emit(text, level, timeout_ms)
[docs]
def get_color_service(self) -> ColorService:
"""
Get the application's color service.
:return: The color service instance
:rtype: ColorService
"""
return self._color_service
[docs]
def register_new_window_type(self, kind: str, view_cls: type[SubWindowProtocol], overwrite: bool = False) -> None:
"""
Register a new window type with the application.
:param kind: The identifier for the new window type
:type kind: str
:param view_cls: The window class to register
:type view_cls: type[SubWindowProtocol]
:param overwrite: Whether to overwrite existing registration
:type overwrite: bool
"""
self._window_factory.register(kind, view_cls, overwrite=overwrite)
[docs]
def register_new_overlay(self, overlay_type: str, overlay_role: OverlayRole, renderer: RendererFn) -> None:
"""
Register a new overlay type with the application.
:param overlay_type: The identifier for the new overlay type
:type overlay_type: str
:param overlay_role: The role this overlay plays
:type overlay_role: OverlayRole
:param renderer: The renderer function for this overlay
:type renderer: RendererFn
"""
self._overlay_factory.register(overlay_type, overlay_role, renderer)
[docs]
def register_workspace_reference(self, obj_id: UUID, obj: object) -> None:
"""
Register an object with a unique identifier in the workspace reference manager.
This method allows tools to store objects with a specific ID so they can be
retrieved later using the ID. This is useful for maintaining references to
objects across different tool sessions or operations.
:param obj_id: The unique identifier for the object
:type obj_id: UUID
:param obj: The object to register
:type obj: object
"""
self._workspace_reference_manger.register(obj_id, obj)
[docs]
def get_reference_by_id(self, obj_id: UUID) -> object:
"""
Retrieve an object by its unique identifier from the workspace reference manager.
This method fetches an object that was previously registered with the given ID.
If no object is found with the specified ID, this method will raise a KeyError.
:param obj_id: The unique identifier of the object to retrieve
:type obj_id: UUID
:return: The registered object
:rtype: object
"""
return self._workspace_reference_manger.resolve(obj_id)
[docs]
def register_tool_controller(self, controller: 'ToolController[Any, Any]') -> None:
"""
Register a tool controller in this context.
:param controller: Tool controller instance
:type controller: ToolController
"""
self._tool_controllers.append(controller)
[docs]
def clear_tool_controllers(self) -> None:
"""
Clear all registered tool controllers.
"""
self._tool_controllers.clear()
[docs]
def iter_tool_controllers(self) -> tuple['ToolController[Any, Any]', ...]:
"""
Return all registered tool controllers.
:return: A tuple of registered controllers
:rtype: tuple[ToolController, ...]
"""
return tuple(self._tool_controllers)
[docs]
def collect_dependency_counts(self, image_ids: list[UUID]) -> dict[UUID, dict[str, int]]:
"""
Collect dependency counts for the specified image IDs.
:param image_ids: Image controller UUIDs to check
:type image_ids: list[UUID]
:return: Mapping of image_id to per-tool dependency counts
:rtype: dict[UUID, dict[str, int]]
"""
counts: dict[UUID, dict[str, int]] = {image_id: {} for image_id in image_ids}
if not image_ids:
return counts
by_id = {window.id: window for window in self._window_manager.window_list}
for controller in self.iter_tool_controllers():
for image_id in image_ids:
window = by_id.get(image_id)
if window is None:
continue
count_fn = getattr(controller, 'count_dependencies_for_image', None)
if count_fn is None:
continue
count = count_fn(window)
if count:
counts[image_id][controller.tool_id] = count
return counts