Source code for radioviz.controllers.sub_window_controller

#  Copyright 2025–2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Module for managing sub-windows and their controllers in the radioviz application.

This module provides the core functionality for handling different types of sub-windows
such as image windows, profile windows, and region windows. It includes the base controller
class for sub-windows and utilities for managing window states and flags.

The module defines the :class:`SubWindowEnum` enumeration for identifying different
sub-window types, and the :class:`SubWindowController` base class that manages the
lifecycle and behavior of sub-windows including view management, tool activation,
and overlay handling.

It also provides utility functions for comparing sub-window flags and helper methods
for managing data state, saving operations, and window lifecycle events.
"""

from __future__ import annotations

import functools
import operator
import sys
import uuid
from enum import Flag, auto
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Optional

from PySide6.QtCore import QObject, Signal

from radioviz.models.data_model import DataKind, DataSource, DataState, DataStorage
from radioviz.models.menu_spec import ToolMenuSpec
from radioviz.models.overlays import OverlayManager
from radioviz.services.application_services import ApplicationServices
from radioviz.services.save_services import SaveSpec
from radioviz.services.typing_helpers import require_not_none
from radioviz.services.workspace_manager import WindowState
from radioviz.typing.sub_window_types import SubWindowViewProtocol

if sys.version_info >= (3, 10):
    from typing import TypeVar
else:
    from typing_extensions import TypeVar


if TYPE_CHECKING:
    from radioviz.tools.base_controller import ToolController

T_SubWindow = TypeVar('T_SubWindow', bound=SubWindowViewProtocol)
T_SubWindowController = TypeVar('T_SubWindowController', bound='SubWindowController[Any]')


[docs] class SubWindowEnum(Flag): """ Enumeration representing different types of sub-windows in the application. This enum is used to identify and manage different sub-window types such as image windows, profile windows, and region windows. It supports bitwise operations to combine multiple window types. The :attr:`ALL` attribute contains a combination of all non-NONE members. This enum is used for UI enablement and categorization only; window instantiation is handled separately by the WindowFactory via string window type identifiers. """ NONE = 0 ImageWindow = auto() ProfileWindow = auto() RegionWindow = auto() MetadataWindow = auto() ALL: ClassVar['SubWindowEnum'] # it will be overwritten later
# Create the ALL flag by combining all non-NONE members flag_members = [member for member in SubWindowEnum.__members__.values() if member.value != 0] if flag_members: all_value: SubWindowEnum = functools.reduce(operator.or_, flag_members) else: all_value = SubWindowEnum.NONE # there is only NONE and this the all is just NONE SubWindowEnum.ALL = SubWindowEnum(all_value)
[docs] def compare_flags(current_flag: SubWindowEnum, reference_flag: SubWindowEnum) -> bool: """ Compare two sub-window flags to check if current flag is contained in reference flag. This utility function checks whether a specific sub-window type is included within a combination of sub-window types. :param current_flag: The flag to check :type current_flag: SubWindowEnum :param reference_flag: The reference flag to check against :type reference_flag: SubWindowEnum :return: True if current_flag is contained in reference_flag, False otherwise :rtype: bool """ if current_flag == SubWindowEnum.NONE or reference_flag == SubWindowEnum.NONE: return False return current_flag in reference_flag
[docs] class SubWindowController(QObject, Generic[T_SubWindow]): """ Base controller class for managing sub-windows in the radioviz application. This controller handles the lifecycle and behavior of sub-windows including view management, tool activation, and overlay handling. The controller maintains the data state through :attr:`state` and provides methods for marking data as modified or saved. It also manages the association between the controller and its view through :meth:`set_view`. The controller implements methods for saving operations including checking if saving is possible (:meth:`can_save`) and what save specifications are available (:meth:`get_save_specs`). It also provides methods for determining if the window is dirty (:meth:`is_dirty`) or saved (:meth:`is_saved`). """ sub_window_type = SubWindowEnum.NONE about_to_close = Signal() state_changed = Signal() context_menu_requested = Signal(object, object) message_requested = Signal(str, str, int) # text, level, timeout def __init__(self, source: DataSource, storage: Optional[DataStorage] = None, view: Optional[T_SubWindow] = None): """ Initialize the sub-window controller. :param source: The data source for this controller :type source: DataSource :param storage: Optional data storage for this controller :type storage: Optional[DataStorage] :param view: Optional view instance to associate with this controller :type view: Optional[T_SubWindow] """ super().__init__() self.id = uuid.uuid4() self.source = source self.storage = storage or DataStorage(path=None) self.kind = self.derive_kind() self.state = self.derive_state() self.view: Optional[T_SubWindow] = view self.active_tool: Optional['ToolController[Any, Any]'] = None self.name = 'window_name' self.overlay_manager = OverlayManager() self.application_services = ApplicationServices()
[docs] def derive_kind(self) -> DataKind: """ Determine the kind of data based on the source parent. If the source has no parent, it's considered original data. Otherwise, it's considered derived data. :return: The data kind (ORIGINAL or DERIVED) :rtype: DataKind """ if self.source.parent is None: return DataKind.ORIGINAL return DataKind.DERIVED
[docs] def derive_state(self) -> DataState: """ Derive the current data state based on storage path. If the storage path is set, the state is PERSISTENT. Otherwise, it's MODIFIED. :return: The current data state :rtype: DataState """ state = DataState.NONE if self.storage.path is not None: state |= DataState.PERSISTENT else: state |= DataState.MODIFIED return state
[docs] def mark_modified(self) -> None: """ Mark the data as modified and emit state changed signal. This method sets the MODIFIED flag in the controller's state and emits the :attr:`state_changed` signal to notify observers of the change. """ self.state |= DataState.MODIFIED self.state_changed.emit()
[docs] def mark_saved(self, path: Path) -> None: """ Mark the data as saved with the given path. Updates the storage path, removes the MODIFIED flag, adds the PERSISTENT flag, and updates the window name. Emits the :attr:`state_changed` signal. :param path: The path where the data was saved :type path: Path """ self.storage.path = path self.state &= ~DataState.MODIFIED self.state |= DataState.PERSISTENT self.name = path.name new_data_source = DataSource( label=path.name, origin=self.source.origin, parent=self.source.parent, derivation=self.source.derivation ) self.source = new_data_source self.state_changed.emit()
[docs] def is_dirty(self) -> bool: """ Check if the data has been modified but not yet saved. :return: True if the data is dirty (modified), False otherwise :rtype: bool """ return DataState.MODIFIED in self.state
[docs] def is_saved(self) -> bool: """ Check if the data has been saved to persistent storage. :return: True if the data is saved, False otherwise :rtype: bool """ return DataState.PERSISTENT in self.state
[docs] def get_save_specs(self) -> list[SaveSpec]: """ Get the list of available save specifications for this controller. This method should be overridden by subclasses to provide actual save specs. :return: List of save specifications :rtype: list[SaveSpec] """ return []
[docs] def can_save(self) -> bool: """ Check if the current data can be saved. A data can be saved if: 1. The storage path is set 2. The data is marked as modified 3. There are save specifications available :return: True if the data can be saved, False otherwise :rtype: bool """ return ( self.storage.path is not None # the storage path must be set already and DataState.MODIFIED in self.state # # it must be modified and len(self.get_save_specs()) != 0 # it must have at least one save_spec )
[docs] def can_save_as(self) -> bool: """ Check if the current data can be saved with a new path. A data can be saved as if there are save specifications available. :return: True if the data can be saved as, False otherwise :rtype: bool """ return len(self.get_save_specs()) > 0
[docs] def current_path(self) -> Path | None: """ Get the current storage path. :return: The current storage path or None if not set :rtype: Path | None """ return self.storage.path
[docs] def set_view(self, view: T_SubWindow) -> None: """ Set the view associated with this controller. Connects the view's context menu signal to the controller's signal. :param view: The view to associate with this controller :type view: T_SubWindow """ self.view = view self.view.context_menu_requested.connect(self.context_menu_requested) self.state_changed.connect(self.view.on_data_state_changed)
[docs] def require_view(self) -> T_SubWindow: """ Return the associated view or raise if missing. :return: The view instance associated with this controller :rtype: T_SubWindow :raises RuntimeError: If the controller has no view attached """ return require_not_none(self.view, 'Sub-window controller view')
[docs] def on_view_closed(self) -> None: """ Handle the event when the view is closed. This method is called when the associated view is closed. Subclasses can override this method to implement custom cleanup logic. """ pass
[docs] def on_view_created(self) -> None: """ Handle the event when the view is created. This method is called when the associated view is created. Subclasses can override this method to implement custom initialization logic. """ pass
[docs] def window_state(self) -> WindowState: """ Get the current window state from the associated view. If no view is associated, returns WindowState.Normal. :return: The current window state :rtype: WindowState """ if self.view is None: return WindowState.Normal return self.view.window_state()
[docs] def set_window_state(self, state: WindowState) -> None: """ Set the window state for the associated view. If no view is associated, this method does nothing. :param state: The window state to set :type state: WindowState """ if self.view is None: return self.view.set_window_state(state)
[docs] def close(self) -> None: """Close the corresponding view""" if self.view: self.view.close()
[docs] def context_menu_specs(self) -> list[ToolMenuSpec]: """ Return window-specific context menu specifications. Subclasses can override this to provide built-in window menus that should appear in the context menu for the active window. Tool menus are filtered separately based on tool compatibility with the window type. :return: List of menu specifications. :rtype: list[ToolMenuSpec] """ return []
[docs] def sync_context_menu_state(self) -> None: """ Sync context menu state for window-specific menus. Subclasses can override this to emit signals that update checkable action states before the context menu is shown. """ return