# 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()