# 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:: SimpleObjectTreeGet object dialog-----------------.. autoclass:: GetObjectsDialogObject view-----------.. autoclass:: ObjectView"""# pylint: disable=invalid-name # Allows short reference names like x, y, ...from__future__importannotationsimportosfromcollections.abcimportIteratorfromtypingimportTYPE_CHECKINGfromguidata.configtoolsimportget_iconfromqtpyimportQtCoreasQCfromqtpyimportQtGuiasQGfromqtpyimportQtWidgetsasQWfromcdl.configimport_fromcdl.core.gui.objectmodelimportObjectGroupfromcdl.core.model.imageimportImageObjfromcdl.core.model.signalimportSignalObjfromcdl.utils.qthelpersimportblock_signalsifTYPE_CHECKING:fromtypingimportAnyfromcdl.core.gui.objectmodelimportObjectModelfromcdl.core.gui.panel.baseimportBaseDataPaneldefmetadata_to_html(metadata:dict[str,Any])->str:"""Convert metadata to human-readable string. Returns: HTML string """textlines=[]forkey,valueinmetadata.items():iflen(textlines)>5:textlines.append("[...]")breakifnotkey.startswith("_"):vlines=str(value).splitlines()ifvlines:text=f"<b>{key}:</b> {vlines[0]}"iflen(vlines)>1:text+=" [...]"textlines.append(text)iftextlines:ptit=_("Object metadata")psub=_("(click on Metadata button for more details)")prefix=f"<i><u>{ptit}:</u> {psub}</i><br>"returnf"<p style='white-space:pre'>{prefix}{'<br>'.join(textlines)}</p>"return""
[docs]definitialize_from(self,sobjlist:SimpleObjectTree)->None:"""Init from another SimpleObjectList, without making copies of objects"""self.objmodel=sobjlist.objmodelself.populate_tree()self.set_current_item_id(sobjlist.get_current_item_id())
[docs]defiter_items(self,item:QW.QTreeWidgetItem|None=None)->Iterator[QW.QTreeWidgetItem]:"""Recursively iterate over all items"""ifitemisNone:forindexinrange(self.topLevelItemCount()):yield fromself.iter_items(self.topLevelItem(index))else:yielditemforindexinrange(item.childCount()):yield fromself.iter_items(item.child(index))
[docs]defget_item_from_id(self,item_id)->QW.QTreeWidgetItem:"""Return QTreeWidgetItem from id (stored in item's data)"""foriteminself.iter_items():ifitem.data(0,QC.Qt.UserRole)==item_id:returnitemreturnNone
[docs]defget_current_item_id(self,object_only:bool=False)->str|None:"""Return current item id"""item=self.currentItem()ifitemisnotNoneand(notobject_onlyoritem.parent()isnotNone):returnitem.data(0,QC.Qt.UserRole)returnNone
[docs]defset_current_item_id(self,uuid:str,extend:bool=False)->None:"""Set current item by id"""item=self.get_item_from_id(uuid)ifextend:self.setCurrentItem(item,0,QC.QItemSelectionModel.Select)else:self.setCurrentItem(item)
[docs]defget_current_group_id(self)->str:"""Return current group ID"""selected_item=self.currentItem()ifselected_itemisNone:returnNoneifselected_item.parent()isNone:returnselected_item.data(0,QC.Qt.UserRole)returnselected_item.parent().data(0,QC.Qt.UserRole)
[docs]defget_sel_group_items(self)->list[QW.QTreeWidgetItem]:"""Return selected group items"""return[itemforiteminself.selectedItems()ifitem.parent()isNone]
[docs]defget_sel_group_uuids(self)->list[str]:"""Return selected group uuids"""return[item.data(0,QC.Qt.UserRole)foriteminself.get_sel_group_items()]
[docs]defget_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()ifnotsel_items:cur_item=self.currentItem()ifcur_itemisnotNoneandcur_item.parent()isnotNone:sel_items=[cur_item]uuids=[item.data(0,QC.Qt.UserRole)foriteminsel_items]ifinclude_groups:forgroup_idinself.get_sel_group_uuids():uuids.extend(self.objmodel.get_group_object_ids(group_id))returnuuids
[docs]defget_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]foroidinself.get_sel_object_uuids(include_groups)]
[docs]defpopulate_tree(self)->None:"""Populate tree with objects"""uuid=self.get_current_item_id()withblock_signals(widget=self,enable=True):self.clear()forgroupinself.objmodel.get_groups():self.add_group_item(group)ifuuidisnotNone:self.set_current_item_id(uuid)
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"ifisinstance(obj,SignalObj)else"image.svg"item.setIcon(0,get_icon(icon))self.__update_item(item,obj)group_item.addChild(item)
[docs]defadd_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)forobjingroup:self.__add_to_group_item(obj,group_item)
[docs]defremove_item(self,oid:str,refresh:bool=True)->None:"""Remove item"""item=self.get_item_from_id(oid)ifitemisnotNone:withblock_signals(widget=self,enable=notrefresh):ifitem.parent()isNone:# Group item: remove from treeself.takeTopLevelItem(self.indexOfTopLevelItem(item))else:# Object item: remove from parentitem.parent().removeChild(item)
[docs]defitem_double_clicked(self,item:QW.QTreeWidgetItem)->None:"""Item was double-clicked: open a pop-up plot dialog"""ifitem.parent()isnotNone:oid=item.data(0,QC.Qt.UserRole)self.SIG_ITEM_DOUBLECLICKED.emit(oid)
[docs]classGetObjectsDialog(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_objectsself.__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(lambdaoid:self.accept())self.tree.itemSelectionChanged.connect(self.__item_selection_changed)ifnb_objects>1:self.tree.setSelectionMode(QW.QAbstractItemView.ExtendedSelection)vlayout.addWidget(self.tree)ifcomment: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()ifminimum_sizeisnotNone:self.setMinimumSize(*minimum_size)else:self.setMinimumWidth(400)def__item_selection_changed(self)->None:"""Item selection has changed"""nobj=self.__nb_objectsself.__selected_objects=self.tree.get_sel_objects(include_groups=nobj>1)self.ok_btn.setEnabled(len(self.__selected_objects)==nobj)
[docs]classObjectView(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]defpaintEvent(self,event):# pylint: disable=C0103"""Reimplement Qt method"""super().paintEvent(event)iflen(self.objmodel)>0:returnpainter=QG.QPainter(self.viewport())painter.drawText(self.rect(),QC.Qt.AlignCenter,_("Drag files here to open"))
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-statementsifevent.mimeData().hasUrls():returnTruedrop_pos=self.dropIndicatorPosition()on_item=drop_pos==QW.QAbstractItemView.OnItemabove_item=drop_pos==QW.QAbstractItemView.AboveItembelow_item=drop_pos==QW.QAbstractItemView.BelowItemon_viewport=drop_pos==QW.QAbstractItemView.OnViewporttarget_item=self.itemAt(event.pos())# If moved items are objects, refuse the drop on the viewportifself.__dragged_objectsandon_viewport:returnFalse# If drop indicator is on an item, refuse the drop if the target item# is anything but a groupifon_itemand(target_itemisNoneortarget_item.parent()isnotNone):returnFalse# If drop indicator is on an item, refuse the drop if the moved items# are groupsifon_itemandself.__dragged_groups:returnFalse# 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.iftarget_itemisNoneoron_viewport:returnTrue# If moved items are groups, refuse the drop if the target item is# not a groupifself.__dragged_groupsandtarget_item.parent()isnotNone:returnFalse# 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 aboveifself.__dragged_groupsandbelow_item:returnFalse# 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 itemifself.__dragged_objectsandtarget_item.parent()isNoneandnoton_item:returnFalse# 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 itemif(self.__dragged_objectsandtarget_item.parent()isNoneandself.indexFromItem(target_item).row()==0andabove_item):returnFalsereturnTrue
[docs]defget_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)forindexinrange(self.topLevelItemCount())]
[docs]defget_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)foridxinrange(self.topLevelItem(index).childCount())]forindex,group_idinenumerate(self.get_all_group_uuids())}
[docs]defdropEvent(self,event:QG.QDropEvent)->None:# pylint: disable=C0103"""Reimplement Qt method"""ifevent.mimeData().hasUrls():fnames=[url.toLocalFile()forurlinevent.mimeData().urls()]self.SIG_IMPORT_FILES.emit(fnames)event.setDropAction(QC.Qt.CopyAction)event.accept()else:is_allowed=self.__is_drop_allowed(event)ifnotis_allowed:event.ignore()else:drop_pos=self.dropIndicatorPosition()on_viewport=drop_pos==QW.QAbstractItemView.OnViewporttarget_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.iftarget_itemisNoneoron_viewport:# If moved items are groups, move them to the end of the listifself.__dragged_groups:foriteminself.__dragged_groups:self.takeTopLevelItem(self.indexOfTopLevelItem(item))self.addTopLevelItem(item)# If moved items are objects, move them to the last groupifself.__dragged_objects:lastgrp_item=self.topLevelItem(self.topLevelItemCount()-1)foriteminself.__dragged_objects:item.parent().removeChild(item)lastgrp_item.addChild(item)event.accept()else:super().dropEvent(event)ifevent.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:ifself.__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 modelself.objmodel.reorder_groups(gids)# Now, let's consider case 2:ifself.__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 modelself.objmodel.reorder_objects(oids)# Finally, we need to update treeself.update_tree()# Restore expanded states of moved groupsforiteminself.__dragged_groups:item.setExpanded(self.__dragged_expanded_states[item.data(0,QC.Qt.UserRole)])# Restore selection, either of groups or objectssel_items=self.__dragged_groupsorself.__dragged_objectsextend=len(sel_items)>1foriteminsel_items:ifextend:self.setCurrentItem(item,0,QC.QItemSelectionModel.Select)else:self.setCurrentItem(item)
[docs]defget_current_object(self)->SignalObj|ImageObj|None:"""Return current object"""oid=self.get_current_item_id(object_only=True)ifoidisnotNone:returnself.objmodel[oid]returnNone
[docs]defset_current_object(self,obj:SignalObj|ImageObj)->None:"""Set current object"""self.set_current_item_id(obj.uuid)
[docs]defitem_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()ifsel_groups:foriteminself.get_sel_object_items():item.setSelected(False)ifself.currentItem().parent()isnotNone:self.setCurrentItem(sel_groups[0])self.SIG_SELECTION_CHANGED.emit()
[docs]defselect_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 """ifall(isinstance(obj,int)forobjinselection):all_uuids=self.objmodel.get_object_ids()uuids=[all_uuids[num-1]fornuminselection]elifall(isinstance(obj,str)forobjinselection):uuids=selectionelse:assertall(isinstance(obj,(SignalObj,ImageObj))forobjinselection)uuids=[obj.uuidforobjinselection]foridx,uuidinenumerate(uuids):self.set_current_item_id(uuid,extend=idx>0)
[docs]defselect_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. """ifgroupsisNone:groups=self.objmodel.get_groups()elifall(isinstance(group,int)forgroupingroups):groups=[self.objmodel.get_groups()[grp_num-1]forgrp_numingroups]elifall(isinstance(group,str)forgroupingroups):groups=self.objmodel.get_groups(groups)assertall(isinstance(group,ObjectGroup)forgroupingroups)foridx,groupinenumerate(groups):self.set_current_item_id(group.uuid,extend=idx>0)
[docs]defmove_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 treesel_objs.sort(key=lambdaitem:self.indexFromItem(item).row())sel_groups.sort(key=lambdaitem:self.indexFromItem(item).row())ifnotsel_objsandnotsel_groups:returnifsel_objs:foriteminsel_objs:parent=item.parent()idx_item=parent.indexOfChild(item)idx_parent=self.indexOfTopLevelItem(parent)ifidx_item>0:parent.takeChild(idx_item)parent.insertChild(idx_item-1,item)elifidx_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:returnelse:# Store groups expanded stateexpstates={item.data(0,QC.Qt.UserRole):item.isExpanded()foriteminsel_groups}foriteminsel_groups:idx_item=self.indexOfTopLevelItem(item)ifidx_item>0:self.takeTopLevelItem(idx_item)self.insertTopLevelItem(idx_item-1,item)else:return# Restore groups expanded stateforiteminsel_groups:item.setExpanded(expstates[item.data(0,QC.Qt.UserRole)])self.__reorder_model()# Restore selectionforiteminsel_objs+sel_groups:item.setSelected(True)
[docs]defmove_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 treesel_objs.sort(key=lambdaitem:self.indexFromItem(item).row(),reverse=True)sel_groups.sort(key=lambdaitem:self.indexFromItem(item).row(),reverse=True)ifnotsel_objsandnotsel_groups:returnifsel_objs:foriteminsel_objs:parent=item.parent()idx_item=parent.indexOfChild(item)idx_parent=self.indexOfTopLevelItem(parent)ifidx_item<parent.childCount()-1:parent.takeChild(idx_item)parent.insertChild(idx_item+1,item)elifidx_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:returnelse:# Store groups expanded stateexpstates={item.data(0,QC.Qt.UserRole):item.isExpanded()foriteminsel_groups}foriteminsel_groups:idx_item=self.indexOfTopLevelItem(item)ifidx_item<self.topLevelItemCount()-1:self.takeTopLevelItem(idx_item)self.insertTopLevelItem(idx_item+1,item)else:return# Restore groups expanded stateforiteminsel_groups:item.setExpanded(expstates[item.data(0,QC.Qt.UserRole)])self.__reorder_model()# Restore selectionforiteminsel_objs+sel_groups:item.setSelected(True)