Source code for cdl.core.gui.objectview

# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
Object view
===========

The :mod:`cdl.core.gui.objectview` module provides widgets to display object
(signal/image) trees.

.. note::

    This module provides tree widgets to display signals, images and groups. It
    is important to note that, by design, the user can only select either individual
    signals/images or groups, but not both at the same time. This is an important
    design choice, as it allows to simplify the user experience, and to avoid
    potential confusion between the two types of selection.

Simple object tree
------------------

.. autoclass:: SimpleObjectTree

Get object dialog
-----------------

.. autoclass:: GetObjectsDialog

Object view
-----------

.. autoclass:: ObjectView
"""

# pylint: disable=invalid-name  # Allows short reference names like x, y, ...

from __future__ import annotations

import os
from collections.abc import Iterator
from typing import TYPE_CHECKING

from guidata.configtools import get_icon
from qtpy import QtCore as QC
from qtpy import QtGui as QG
from qtpy import QtWidgets as QW

from cdl.config import _
from cdl.core.gui.objectmodel import ObjectGroup
from cdl.core.model.image import ImageObj
from cdl.core.model.signal import SignalObj
from cdl.utils.qthelpers import block_signals

if TYPE_CHECKING:
    from typing import Any

    from cdl.core.gui.objectmodel import ObjectModel
    from cdl.core.gui.panel.base import BaseDataPanel


def metadata_to_html(metadata: dict[str, Any]) -> str:
    """Convert metadata to human-readable string.

    Returns:
        HTML string
    """
    textlines = []
    for key, value in metadata.items():
        if len(textlines) > 5:
            textlines.append("[...]")
            break
        if not key.startswith("_"):
            vlines = str(value).splitlines()
            if vlines:
                text = f"<b>{key}:</b> {vlines[0]}"
                if len(vlines) > 1:
                    text += " [...]"
                textlines.append(text)
    if textlines:
        ptit = _("Object metadata")
        psub = _("(click on Metadata button for more details)")
        prefix = f"<i><u>{ptit}:</u> {psub}</i><br>"
        return f"<p style='white-space:pre'>{prefix}{'<br>'.join(textlines)}</p>"
    return ""


[docs] class SimpleObjectTree(QW.QTreeWidget): """Base object handling panel list widget, object (sig/ima) lists""" SIG_ITEM_DOUBLECLICKED = QC.Signal(str) SIG_CONTEXT_MENU = QC.Signal(QC.QPoint) def __init__(self, parent: QW.QWidget, objmodel: ObjectModel) -> None: self.objmodel: ObjectModel = objmodel super().__init__(parent) self.setHeaderHidden(True) self.setColumnCount(1) self.setAlternatingRowColors(True) self.itemDoubleClicked.connect(self.item_double_clicked) def __str__(self) -> str: """Return string representation""" textlist = [] for tl_index in range(self.topLevelItemCount()): tl_item = self.topLevelItem(tl_index) textlist.append(tl_item.text(0)) for index in range(tl_item.childCount()): textlist.append(" " + tl_item.child(index).text(0)) return os.linesep.join(textlist)
[docs] def initialize_from(self, sobjlist: SimpleObjectTree) -> None: """Init from another SimpleObjectList, without making copies of objects""" self.objmodel = sobjlist.objmodel self.populate_tree() self.set_current_item_id(sobjlist.get_current_item_id())
[docs] def iter_items( self, item: QW.QTreeWidgetItem | None = None ) -> Iterator[QW.QTreeWidgetItem]: """Recursively iterate over all items""" if item is None: for index in range(self.topLevelItemCount()): yield from self.iter_items(self.topLevelItem(index)) else: yield item for index in range(item.childCount()): yield from self.iter_items(item.child(index))
[docs] def get_item_from_id(self, item_id) -> QW.QTreeWidgetItem: """Return QTreeWidgetItem from id (stored in item's data)""" for item in self.iter_items(): if item.data(0, QC.Qt.UserRole) == item_id: return item return None
[docs] def get_current_item_id(self, object_only: bool = False) -> str | None: """Return current item id""" item = self.currentItem() if item is not None and (not object_only or item.parent() is not None): return item.data(0, QC.Qt.UserRole) return None
[docs] def set_current_item_id(self, uuid: str, extend: bool = False) -> None: """Set current item by id""" item = self.get_item_from_id(uuid) if extend: self.setCurrentItem(item, 0, QC.QItemSelectionModel.Select) else: self.setCurrentItem(item)
[docs] def get_current_group_id(self) -> str: """Return current group ID""" selected_item = self.currentItem() if selected_item is None: return None if selected_item.parent() is None: return selected_item.data(0, QC.Qt.UserRole) return selected_item.parent().data(0, QC.Qt.UserRole)
[docs] def get_sel_group_items(self) -> list[QW.QTreeWidgetItem]: """Return selected group items""" return [item for item in self.selectedItems() if item.parent() is None]
[docs] def get_sel_group_uuids(self) -> list[str]: """Return selected group uuids""" return [item.data(0, QC.Qt.UserRole) for item in self.get_sel_group_items()]
[docs] def get_sel_object_items(self) -> list[QW.QTreeWidgetItem]: """Return selected object items""" return [item for item in self.selectedItems() if item.parent() is not None]
[docs] def get_sel_object_uuids(self, include_groups: bool = False) -> list[str]: """Return selected objects uuids. Args: include_groups: If True, also return objects from selected groups. Returns: List of selected objects uuids. """ sel_items = self.get_sel_object_items() if not sel_items: cur_item = self.currentItem() if cur_item is not None and cur_item.parent() is not None: sel_items = [cur_item] uuids = [item.data(0, QC.Qt.UserRole) for item in sel_items] if include_groups: for group_id in self.get_sel_group_uuids(): uuids.extend(self.objmodel.get_group_object_ids(group_id)) return uuids
[docs] def get_sel_objects( self, include_groups: bool = False ) -> list[SignalObj | ImageObj]: """Return selected objects. If include_groups is True, also return objects from selected groups.""" return [self.objmodel[oid] for oid in self.get_sel_object_uuids(include_groups)]
[docs] def get_sel_groups(self) -> list[ObjectGroup]: """Return selected groups""" return self.objmodel.get_groups(self.get_sel_group_uuids())
@staticmethod def __update_item( item: QW.QTreeWidgetItem, obj: SignalObj | ImageObj | ObjectGroup ) -> None: """Update item""" item.setText(0, f"{obj.short_id}: {obj.title}") if isinstance(obj, (SignalObj, ImageObj)): item.setToolTip(0, metadata_to_html(obj.metadata)) item.setData(0, QC.Qt.UserRole, obj.uuid)
[docs] def populate_tree(self) -> None: """Populate tree with objects""" uuid = self.get_current_item_id() with block_signals(widget=self, enable=True): self.clear() for group in self.objmodel.get_groups(): self.add_group_item(group) if uuid is not None: self.set_current_item_id(uuid)
[docs] def update_tree(self) -> None: """Update tree""" for group in self.objmodel.get_groups(): self.__update_item(self.get_item_from_id(group.uuid), group) for obj in group: self.__update_item(self.get_item_from_id(obj.uuid), obj)
def __add_to_group_item( self, obj: SignalObj | ImageObj, group_item: QW.QTreeWidgetItem ) -> None: """Add object to group item""" item = QW.QTreeWidgetItem() icon = "signal.svg" if isinstance(obj, SignalObj) else "image.svg" item.setIcon(0, get_icon(icon)) self.__update_item(item, obj) group_item.addChild(item)
[docs] def add_group_item(self, group: ObjectGroup) -> None: """Add group item""" group_item = QW.QTreeWidgetItem() group_item.setIcon(0, get_icon("group.svg")) self.__update_item(group_item, group) self.addTopLevelItem(group_item) group_item.setExpanded(True) for obj in group: self.__add_to_group_item(obj, group_item)
[docs] def add_object_item( self, obj: SignalObj | ImageObj, group_id: str, set_current: bool = True ) -> None: """Add item""" group_item = self.get_item_from_id(group_id) self.__add_to_group_item(obj, group_item) if set_current: self.set_current_item_id(obj.uuid)
[docs] def update_item(self, uuid: str) -> None: """Update item""" obj_or_group = self.objmodel.get_object_or_group(uuid) item = self.get_item_from_id(uuid) self.__update_item(item, obj_or_group)
[docs] def remove_item(self, oid: str, refresh: bool = True) -> None: """Remove item""" item = self.get_item_from_id(oid) if item is not None: with block_signals(widget=self, enable=not refresh): if item.parent() is None: # Group item: remove from tree self.takeTopLevelItem(self.indexOfTopLevelItem(item)) else: # Object item: remove from parent item.parent().removeChild(item)
[docs] def item_double_clicked(self, item: QW.QTreeWidgetItem) -> None: """Item was double-clicked: open a pop-up plot dialog""" if item.parent() is not None: oid = item.data(0, QC.Qt.UserRole) self.SIG_ITEM_DOUBLECLICKED.emit(oid)
[docs] def contextMenuEvent(self, event: QG.QContextMenuEvent) -> None: # pylint: disable=C0103 """Override Qt method""" self.SIG_CONTEXT_MENU.emit(event.globalPos())
[docs] class GetObjectsDialog(QW.QDialog): """Dialog box showing groups and objects (signals or images) to select one, or more. Args: parent: parent widget panel: data panel title: dialog title comment: optional dialog comment nb_objects: number of objects to select (default: 1) minimum_size: minimum size (width, height) """ def __init__( self, parent: QW.QWidget, panel: BaseDataPanel, title: str, comment: str = "", nb_objects: int = 1, minimum_size: tuple[int, int] | None = None, ) -> None: super().__init__(parent) self.__nb_objects = nb_objects self.__selected_objects: list[SignalObj | ImageObj] = [] self.setWindowTitle(title) vlayout = QW.QVBoxLayout() self.setLayout(vlayout) self.tree = SimpleObjectTree(parent, panel.objmodel) self.tree.initialize_from(panel.objview) self.tree.SIG_ITEM_DOUBLECLICKED.connect(lambda oid: self.accept()) self.tree.itemSelectionChanged.connect(self.__item_selection_changed) if nb_objects > 1: self.tree.setSelectionMode(QW.QAbstractItemView.ExtendedSelection) vlayout.addWidget(self.tree) if comment: lbl = QW.QLabel(comment) lbl.setWordWrap(True) vlayout.addSpacing(10) vlayout.addWidget(lbl) bbox = QW.QDialogButtonBox(QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel) bbox.accepted.connect(self.accept) bbox.rejected.connect(self.reject) self.ok_btn = bbox.button(QW.QDialogButtonBox.Ok) vlayout.addSpacing(10) vlayout.addWidget(bbox) # Update OK button state: self.__item_selection_changed() if minimum_size is not None: self.setMinimumSize(*minimum_size) else: self.setMinimumWidth(400) def __item_selection_changed(self) -> None: """Item selection has changed""" nobj = self.__nb_objects self.__selected_objects = self.tree.get_sel_objects(include_groups=nobj > 1) self.ok_btn.setEnabled(len(self.__selected_objects) == nobj)
[docs] def get_selected_objects(self) -> list[SignalObj | ImageObj]: """Return selected objects""" return self.__selected_objects
[docs] class ObjectView(SimpleObjectTree): """Object handling panel list widget, object (sig/ima) lists""" SIG_SELECTION_CHANGED = QC.Signal() SIG_IMPORT_FILES = QC.Signal(list) def __init__(self, parent: QW.QWidget, objmodel: ObjectModel) -> None: super().__init__(parent, objmodel) self.setSelectionMode(QW.QAbstractItemView.ExtendedSelection) self.setAcceptDrops(True) self.setDragEnabled(True) self.setDragDropMode(QW.QAbstractItemView.InternalMove) self.itemSelectionChanged.connect(self.item_selection_changed) self.__dragged_objects: list[QW.QListWidgetItem] = [] self.__dragged_groups: list[QW.QListWidgetItem] = [] self.__dragged_expanded_states: dict[QW.QListWidgetItem, bool] = {}
[docs] def paintEvent(self, event): # pylint: disable=C0103 """Reimplement Qt method""" super().paintEvent(event) if len(self.objmodel) > 0: return painter = QG.QPainter(self.viewport()) painter.drawText(self.rect(), QC.Qt.AlignCenter, _("Drag files here to open"))
# pylint: disable=unused-argument
[docs] def dragEnterEvent(self, event: QG.QDragEnterEvent) -> None: """Reimplement Qt method""" if event.mimeData().hasUrls(): event.acceptProposedAction() else: super().dragEnterEvent(event) self.__dragged_groups = self.get_sel_group_items() self.__dragged_objects = self.get_sel_object_items() self.__dragged_expanded_states = { item.data(0, QC.Qt.UserRole): item.isExpanded() for item in self.__dragged_groups }
[docs] def dragLeaveEvent(self, event: QG.QDragLeaveEvent) -> None: """Reimplement Qt method""" super().dragLeaveEvent(event) self.__dragged_groups = [] self.__dragged_objects = [] self.__dragged_expanded_states = {}
# pylint: disable=unused-argument
[docs] def dragMoveEvent(self, event: QG.QDragMoveEvent) -> None: """Reimplement Qt method""" self.setDropIndicatorShown(True) if event.mimeData().hasUrls(): event.setDropAction(QC.Qt.CopyAction) event.accept() else: super().dragMoveEvent(event) self.setDropIndicatorShown(self.__is_drop_allowed(event))
def __is_drop_allowed(self, event: QG.QDropEvent | QG.QDragMoveEvent) -> bool: """Return True if drop is allowed""" # Yes, this method has too many return statements. # But it's still quite readable, so let's focus on other things and just disable # the pylint warning. # # pylint: disable=too-many-return-statements if event.mimeData().hasUrls(): return True drop_pos = self.dropIndicatorPosition() on_item = drop_pos == QW.QAbstractItemView.OnItem above_item = drop_pos == QW.QAbstractItemView.AboveItem below_item = drop_pos == QW.QAbstractItemView.BelowItem on_viewport = drop_pos == QW.QAbstractItemView.OnViewport target_item = self.itemAt(event.pos()) # If moved items are objects, refuse the drop on the viewport if self.__dragged_objects and on_viewport: return False # If drop indicator is on an item, refuse the drop if the target item # is anything but a group if on_item and (target_item is None or target_item.parent() is not None): return False # If drop indicator is on an item, refuse the drop if the moved items # are groups if on_item and self.__dragged_groups: return False # If target item is None, it means that the drop position is # outside of the tree. In this case, we accept the drop and move # the objects to the end of the list. if target_item is None or on_viewport: return True # If moved items are groups, refuse the drop if the target item is # not a group if self.__dragged_groups and target_item.parent() is not None: return False # If moved items are groups, refuse the drop if the target item is # a group but the target position is below the target instead of above if self.__dragged_groups and below_item: return False # If moved items are objects, refuse the drop if the target item is # a group and the drop indicator is anything but on the target item if self.__dragged_objects and target_item.parent() is None and not on_item: return False # If moved items are objects, refuse the drop if the target item is # the first group item and the drop position is above the target item if ( self.__dragged_objects and target_item.parent() is None and self.indexFromItem(target_item).row() == 0 and above_item ): return False return True
[docs] def get_all_group_uuids(self) -> list[str]: """Return all group uuids, in a list ordered by group position in the tree""" return [ self.topLevelItem(index).data(0, QC.Qt.UserRole) for index in range(self.topLevelItemCount()) ]
[docs] def get_all_object_uuids(self) -> dict[str, list[str]]: """Return all object uuids, in a dictionary that maps group uuids to the list of object uuids in each group, in the correct order""" return { group_id: [ self.topLevelItem(index).child(idx).data(0, QC.Qt.UserRole) for idx in range(self.topLevelItem(index).childCount()) ] for index, group_id in enumerate(self.get_all_group_uuids()) }
[docs] def dropEvent(self, event: QG.QDropEvent) -> None: # pylint: disable=C0103 """Reimplement Qt method""" if event.mimeData().hasUrls(): fnames = [url.toLocalFile() for url in event.mimeData().urls()] self.SIG_IMPORT_FILES.emit(fnames) event.setDropAction(QC.Qt.CopyAction) event.accept() else: is_allowed = self.__is_drop_allowed(event) if not is_allowed: event.ignore() else: drop_pos = self.dropIndicatorPosition() on_viewport = drop_pos == QW.QAbstractItemView.OnViewport target_item = self.itemAt(event.pos()) # If target item is None, it means that the drop position is # outside of the tree. In this case, we accept the drop and move # the objects to the end of the list. if target_item is None or on_viewport: # If moved items are groups, move them to the end of the list if self.__dragged_groups: for item in self.__dragged_groups: self.takeTopLevelItem(self.indexOfTopLevelItem(item)) self.addTopLevelItem(item) # If moved items are objects, move them to the last group if self.__dragged_objects: lastgrp_item = self.topLevelItem(self.topLevelItemCount() - 1) for item in self.__dragged_objects: item.parent().removeChild(item) lastgrp_item.addChild(item) event.accept() else: super().dropEvent(event) if event.isAccepted(): # Ok, the drop was accepted, so we need to update the model accordingly # (at this stage, the model has not been updated yet but the tree has # been updated already, e.g. by the super().dropEvent(event) calls). # Thus, we have to loop over all tree items and reproduce the tree # structure in the model, by reordering the groups and objects. # We have two cases to consider (that mutually exclude each other): # 1. Groups are moved: we need to reorder the groups in the model # 2. Objects are moved: we need to reorder the objects in all groups # in the model # Let's start with case 1: if self.__dragged_groups: # First, we need to get the list of all groups in the model # (in the correct order) gids = self.get_all_group_uuids() # Then, we need to reorder the groups in the model self.objmodel.reorder_groups(gids) # Now, let's consider case 2: if self.__dragged_objects: # First, we need to get a dictionary that maps group ids to # the list of objects in each group (in the correct order) oids = self.get_all_object_uuids() # Then, we need to reorder the objects in all groups in the model self.objmodel.reorder_objects(oids) # Finally, we need to update tree self.update_tree() # Restore expanded states of moved groups for item in self.__dragged_groups: item.setExpanded( self.__dragged_expanded_states[item.data(0, QC.Qt.UserRole)] ) # Restore selection, either of groups or objects sel_items = self.__dragged_groups or self.__dragged_objects extend = len(sel_items) > 1 for item in sel_items: if extend: self.setCurrentItem(item, 0, QC.QItemSelectionModel.Select) else: self.setCurrentItem(item)
[docs] def get_current_object(self) -> SignalObj | ImageObj | None: """Return current object""" oid = self.get_current_item_id(object_only=True) if oid is not None: return self.objmodel[oid] return None
[docs] def set_current_object(self, obj: SignalObj | ImageObj) -> None: """Set current object""" self.set_current_item_id(obj.uuid)
[docs] def item_selection_changed(self) -> None: """Refreshing the selection of objects and groups, emitting the SIG_SELECTION_CHANGED signal which triggers the update of the object properties panel, the plot items and the actions of the toolbar and menu bar. This method is called when the user selects or deselects items in the tree. It is also called when the user clicks on an item that was already selected. This method emits the SIG_SELECTION_CHANGED signal. """ # ==> This is a very important design choice <== # When a group is selected, all individual objects are deselected, even if # they belong to other groups. This is intended to simplify the user experience. # In other words, the user may either select groups or individual objects, but # not both at the same time. sel_groups = self.get_sel_group_items() if sel_groups: for item in self.get_sel_object_items(): item.setSelected(False) if self.currentItem().parent() is not None: self.setCurrentItem(sel_groups[0]) self.SIG_SELECTION_CHANGED.emit()
[docs] def select_objects( self, selection: list[SignalObj | ImageObj | int | str], ) -> None: """Select multiple objects Args: selection (list): list of objects, object numbers (1 to N) or object uuids """ if all(isinstance(obj, int) for obj in selection): all_uuids = self.objmodel.get_object_ids() uuids = [all_uuids[num - 1] for num in selection] elif all(isinstance(obj, str) for obj in selection): uuids = selection else: assert all(isinstance(obj, (SignalObj, ImageObj)) for obj in selection) uuids = [obj.uuid for obj in selection] for idx, uuid in enumerate(uuids): self.set_current_item_id(uuid, extend=idx > 0)
[docs] def select_groups( self, groups: list[ObjectGroup | int | str] | None = None ) -> None: """Select multiple groups Args: groups: list of groups, group numbers (1 to N), group names or None (select all groups). Defaults to None. """ if groups is None: groups = self.objmodel.get_groups() elif all(isinstance(group, int) for group in groups): groups = [self.objmodel.get_groups()[grp_num - 1] for grp_num in groups] elif all(isinstance(group, str) for group in groups): groups = self.objmodel.get_groups(groups) assert all(isinstance(group, ObjectGroup) for group in groups) for idx, group in enumerate(groups): self.set_current_item_id(group.uuid, extend=idx > 0)
def __reorder_model(self) -> None: """Reorder model""" self.objmodel.reorder_groups(self.get_all_group_uuids()) self.objmodel.reorder_objects(self.get_all_object_uuids()) self.update_tree()
[docs] def move_up(self): """Move selected objects/groups up""" sel_objs = self.get_sel_object_items() sel_groups = self.get_sel_group_items() # Sort selected objects/groups by their position in the tree sel_objs.sort(key=lambda item: self.indexFromItem(item).row()) sel_groups.sort(key=lambda item: self.indexFromItem(item).row()) if not sel_objs and not sel_groups: return if sel_objs: for item in sel_objs: parent = item.parent() idx_item = parent.indexOfChild(item) idx_parent = self.indexOfTopLevelItem(parent) if idx_item > 0: parent.takeChild(idx_item) parent.insertChild(idx_item - 1, item) elif idx_parent > 0: # If the object is the first child of its parent, we check if # there is a group above the parent. If so, we move the object # to the end of the group above. parent.takeChild(idx_item) self.topLevelItem(idx_parent - 1).addChild(item) else: return else: # Store groups expanded state expstates = { item.data(0, QC.Qt.UserRole): item.isExpanded() for item in sel_groups } for item in sel_groups: idx_item = self.indexOfTopLevelItem(item) if idx_item > 0: self.takeTopLevelItem(idx_item) self.insertTopLevelItem(idx_item - 1, item) else: return # Restore groups expanded state for item in sel_groups: item.setExpanded(expstates[item.data(0, QC.Qt.UserRole)]) self.__reorder_model() # Restore selection for item in sel_objs + sel_groups: item.setSelected(True)
[docs] def move_down(self): """Move selected objects/groups down""" sel_objs = self.get_sel_object_items() sel_groups = self.get_sel_group_items() # Sort selected objects/groups by their position in the tree sel_objs.sort(key=lambda item: self.indexFromItem(item).row(), reverse=True) sel_groups.sort(key=lambda item: self.indexFromItem(item).row(), reverse=True) if not sel_objs and not sel_groups: return if sel_objs: for item in sel_objs: parent = item.parent() idx_item = parent.indexOfChild(item) idx_parent = self.indexOfTopLevelItem(parent) if idx_item < parent.childCount() - 1: parent.takeChild(idx_item) parent.insertChild(idx_item + 1, item) elif idx_parent < self.topLevelItemCount() - 1: # If the object is the last child of its parent, we check if # there is a group below the parent. If so, we move the object # to the beginning of the group below. parent.takeChild(idx_item) self.topLevelItem(idx_parent + 1).insertChild(0, item) else: return else: # Store groups expanded state expstates = { item.data(0, QC.Qt.UserRole): item.isExpanded() for item in sel_groups } for item in sel_groups: idx_item = self.indexOfTopLevelItem(item) if idx_item < self.topLevelItemCount() - 1: self.takeTopLevelItem(idx_item) self.insertTopLevelItem(idx_item + 1, item) else: return # Restore groups expanded state for item in sel_groups: item.setExpanded(expstates[item.data(0, QC.Qt.UserRole)]) self.__reorder_model() # Restore selection for item in sel_objs + sel_groups: item.setSelected(True)