Source code for spinetoolbox.spine_db_editor.widgets.custom_qtreeview

######################################################################################################################
# Copyright (C) 2017-2020 Spine project consortium
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################

"""
Classes for custom QTreeView.

:author: M. Marin (KTH)
:date:   25.4.2018
"""

from PySide2.QtWidgets import QTreeView, QMenu
from PySide2.QtCore import Signal, Slot, Qt, QEvent
from PySide2.QtGui import QMouseEvent, QIcon
from spinetoolbox.widgets.custom_qtreeview import CopyTreeView
from spinetoolbox.helpers import busy_effect


[docs]class EntityTreeView(CopyTreeView): """Tree view base class for object and relationship tree views."""
[docs] tree_selection_changed = Signal(dict)
def __init__(self, parent): """Initialize the view.""" super().__init__(parent=parent) self._selected_indexes = {} self._menu = QMenu(self) self._spine_db_editor = None self._fully_expand_action = None self._fully_collapse_action = None self._add_relationship_classes_action = None self._add_relationships_action = None self._manage_relationships_action = None
[docs] def connect_data_store_form(self, spine_db_editor): """Connects a Spine db editor to work with this view. Args: spine_db_editor (SpineDBEditor) """ self._spine_db_editor = spine_db_editor self._create_context_menu() self.connect_signals()
[docs] def _add_middle_actions(self):
"""Adds action at the middle of the context menu. Subclasses can reimplement at will. """
[docs] def _create_context_menu(self): """Creates a context menu for this view.""" self._menu.addAction(self._spine_db_editor.ui.actionCopy) self._menu.addSeparator() self._add_middle_actions() self._menu.addSeparator() self._menu.addAction(self._spine_db_editor.ui.actionEdit_selected) self._menu.addAction(self._spine_db_editor.ui.actionRemove_selected) self._menu.addSeparator() self._fully_expand_action = self._menu.addAction( QIcon(":/icons/menu_icons/angle-double-right.svg"), "Fully expand", self.fully_expand ) self._fully_collapse_action = self._menu.addAction( QIcon(":/icons/menu_icons/angle-double-left.svg"), "Fully collapse", self.fully_collapse ) self._menu.addSeparator() self._menu.addAction("Export selected", self.export_selected)
[docs] def connect_signals(self): """Connects signals.""" self.expanded.connect(self._resize_first_column_to_contents) self.collapsed.connect(self._resize_first_column_to_contents) self.selectionModel().selectionChanged.connect(self._handle_selection_changed)
[docs] def rowsInserted(self, parent, start, end): super().rowsInserted(parent, start, end) self._refresh_selected_indexes()
[docs] def rowsRemoved(self, parent, start, end): super().rowsRemoved(parent, start, end) self._refresh_selected_indexes()
@Slot("QModelIndex")
[docs] def _resize_first_column_to_contents(self, _index=None): self.resizeColumnToContents(0)
@Slot("QItemSelection", "QItemSelection")
[docs] def _handle_selection_changed(self, selected, deselected): """Classifies selection by item type and emits signal.""" self._refresh_selected_indexes() if not self.selectionModel().hasSelection(): return self.refresh_active_member_indexes() parents = set(ind.parent() for ind in deselected) self.model().emit_data_changed_for_column(0, parents) self.tree_selection_changed.emit(self._selected_indexes)
[docs] def _refresh_selected_indexes(self): self._selected_indexes.clear() model = self.model() indexes = self.selectionModel().selectedIndexes() for index in indexes: if not index.isValid() or index.column() != 0: continue item = model.item_from_index(index) self._selected_indexes.setdefault(item.item_type, {})[index] = None
[docs] def refresh_active_member_indexes(self): active_member_indexes = set( index.sibling(row, 0) for index in self._selected_indexes.get("object", ()) for row in index.internalPointer().member_rows ) self.model().set_active_member_indexes(active_member_indexes)
@Slot("QModelIndex", "EditTrigger", "QEvent")
[docs] def edit(self, index, trigger, event): """Edit all selected items.""" if trigger == QTreeView.EditKeyPressed: self.edit_selected() return super().edit(index, trigger, event)
[docs] def clear_any_selections(self): """Clears the selection if any.""" selection_model = self.selectionModel() if selection_model.hasSelection(): selection_model.clearSelection()
@busy_effect
[docs] def fully_expand(self): """Expands selected indexes and all their children.""" self.expanded.disconnect(self._resize_first_column_to_contents) model = self.model() for index in self.selectionModel().selectedIndexes(): if index.column() != 0: continue for item in model.visit_all(index): self.expand(model.index_from_item(item)) self.expanded.connect(self._resize_first_column_to_contents) self._resize_first_column_to_contents()
@busy_effect
[docs] def fully_collapse(self): """Collapses selected indexes and all their children.""" self.collapsed.disconnect(self._resize_first_column_to_contents) model = self.model() for index in self.selectionModel().selectedIndexes(): if index.column() != 0: continue for item in model.visit_all(index): self.collapse(model.index_from_item(item)) self.collapsed.connect(self._resize_first_column_to_contents) self._resize_first_column_to_contents()
[docs] def export_selected(self): """Exports data from selected indexes using the connected Spine db editor.""" self._spine_db_editor.export_selected(self._selected_indexes)
[docs] def remove_selected(self): """Removes selected indexes using the connected Spine db editor.""" self._spine_db_editor.show_remove_entity_tree_items_form(self._selected_indexes)
[docs] def manage_relationships(self): index = self.currentIndex() item = index.internalPointer() relationship_class_key = item.display_id self._spine_db_editor.show_manage_relationships_form(relationship_class_key=relationship_class_key)
[docs] def contextMenuEvent(self, event): """Shows context menu. Args: event (QContextMenuEvent) """ index = self.indexAt(event.pos()) if index.column() != 0: return item = index.model().item_from_index(index) self.update_actions_visibility(item) self._menu.exec_(event.globalPos())
[docs] def mousePressEvent(self, event): """Overrides selection behaviour if the user has selected sticky selection in Settings. If sticky selection is enabled, multiple-selection is enabled when selecting items in the Object tree. Pressing the Ctrl-button down, enables single selection. Args: event (QMouseEvent) """ sticky_selection = self._spine_db_editor.qsettings.value("appSettings/stickySelection", defaultValue="false") if sticky_selection == "false": super().mousePressEvent(event) return local_pos = event.localPos() window_pos = event.windowPos() screen_pos = event.screenPos() button = event.button() buttons = event.buttons() modifiers = event.modifiers() if modifiers & Qt.ControlModifier: modifiers &= ~Qt.ControlModifier else: modifiers |= Qt.ControlModifier source = event.source() new_event = QMouseEvent( QEvent.MouseButtonPress, local_pos, window_pos, screen_pos, button, buttons, modifiers, source ) super().mousePressEvent(new_event)
[docs] def _add_relationship_actions(self): self._add_relationship_classes_action = self._menu.addAction( self._spine_db_editor.ui.actionAdd_relationship_classes.icon(), "Add relationship classes", self.add_relationship_classes, ) self._add_relationships_action = self._menu.addAction( self._spine_db_editor.ui.actionAdd_relationships.icon(), "Add relationships", self.add_relationships ) self._manage_relationships_action = self._menu.addAction( self._spine_db_editor.ui.actionManage_relationships.icon(), "Manage relationships", self.manage_relationships,
)
[docs] def update_actions_visibility(self, item): """Updates the visible property of actions according to whether or not they apply to given item.""" item_has_children = item.has_children() self._fully_expand_action.setVisible(item_has_children) self._fully_collapse_action.setVisible(item_has_children) self._add_relationships_action.setVisible(item.item_type == "relationship_class") self._manage_relationships_action.setVisible(item.item_type == "relationship_class")
[docs] def edit_selected(self): """Edits all selected indexes using the connected Spine db editor.""" self._spine_db_editor.edit_entity_tree_items(self._selected_indexes)
[docs]class ObjectTreeView(EntityTreeView): """Custom QTreeView class for the object tree in SpineDBEditor.""" def __init__(self, parent): """Initialize the view.""" super().__init__(parent=parent) self.add_objects_action = None self.add_object_classes_action = None self.add_object_group_action = None self.manage_object_group_action = None self.duplicate_object_action = None self.find_next_action = None
[docs] def update_actions_visibility(self, item): super().update_actions_visibility(item) self.add_object_classes_action.setVisible(item.item_type == "root") self.add_objects_action.setVisible(item.item_type == "object_class") self.add_object_group_action.setVisible(item.item_type == "object_class") self._add_relationship_classes_action.setVisible(item.item_type == "object_class") self.manage_object_group_action.setVisible(item.item_type == "object" and item.is_group()) self.duplicate_object_action.setVisible(item.item_type == "object" and not item.is_group()) self.find_next_action.setVisible(item.item_type == "relationship")
[docs] def _add_middle_actions(self): self.add_object_classes_action = self._menu.addAction( self._spine_db_editor.ui.actionAdd_object_classes.icon(), "Add objects classes", self.add_object_classes ) self.add_objects_action = self._menu.addAction( self._spine_db_editor.ui.actionAdd_objects.icon(), "Add objects", self.add_objects ) self.add_object_group_action = self._menu.addAction("Add object group", self.add_object_group) self._add_relationship_actions() self._menu.addSeparator() self.find_next_action = self._menu.addAction( QIcon(":/icons/menu_icons/ellipsis-h.png"), "Find next", self.find_next_relationship ) self.manage_object_group_action = self._menu.addAction("Manage object group", self.manage_object_group) self.duplicate_object_action = self._menu.addAction( self._spine_db_editor.ui.actionAdd_objects.icon(), "Duplicate object", self.duplicate_object ) self._menu.addSeparator()
[docs] def connect_signals(self): super().connect_signals() self.doubleClicked.connect(self.find_next_relationship)
[docs] def add_object_classes(self): self._spine_db_editor.show_add_object_classes_form()
[docs] def add_objects(self): index = self.currentIndex() class_name = index.internalPointer().display_data self._spine_db_editor.show_add_objects_form(class_name=class_name)
[docs] def add_relationship_classes(self): index = self.currentIndex() object_class_one_name = index.internalPointer().display_data self._spine_db_editor.show_add_relationship_classes_form(object_class_one_name=object_class_one_name)
[docs] def add_relationships(self): index = self.currentIndex() item = index.internalPointer() relationship_class_key = item.display_id object_name = item.parent_item.display_data object_class_name = item.parent_item.parent_item.display_data object_names_by_class_name = {object_class_name: object_name} self._spine_db_editor.show_add_relationships_form( relationship_class_key=relationship_class_key, object_names_by_class_name=object_names_by_class_name
)
[docs] def find_next_relationship(self): """Finds the next occurrence of the relationship at the current index and expands it.""" index = self.currentIndex() next_index = self.model().find_next_relationship_index(index) if not next_index: return self.setCurrentIndex(next_index) self.scrollTo(next_index) self.expand(next_index)
[docs] def duplicate_object(self): """Duplicate the object at the current index using the connected Spine db editor.""" index = self.currentIndex() self._spine_db_editor.duplicate_object(index)
[docs] def add_object_group(self): index = self.currentIndex() item = index.internalPointer() self._spine_db_editor.show_add_object_group_form(item)
[docs] def manage_object_group(self): index = self.currentIndex() item = index.internalPointer() self._spine_db_editor.show_manage_object_group_form(item)
[docs]class RelationshipTreeView(EntityTreeView): """Custom QTreeView class for the relationship tree in SpineDBEditor."""
[docs] def _add_middle_actions(self): self._add_relationship_actions()
[docs] def update_actions_visibility(self, item): super().update_actions_visibility(item) self._add_relationship_classes_action.setVisible(item.item_type == "root")
[docs] def add_relationship_classes(self): self._spine_db_editor.show_add_relationship_classes_form()
[docs] def add_relationships(self): index = self.currentIndex() item = index.internalPointer() relationship_class_key = item.display_id self._spine_db_editor.show_add_relationships_form(relationship_class_key=relationship_class_key)
[docs]class ItemTreeView(CopyTreeView): """Custom QTreeView class for parameter_value_list in SpineDBEditor. """ def __init__(self, parent): """Initialize the view.""" super().__init__(parent=parent) self._spine_db_editor = None self._menu = QMenu(self)
[docs] def connect_signals(self): """Connects signals.""" self.expanded.connect(self._resize_first_column_to_contents) self.collapsed.connect(self._resize_first_column_to_contents)
@Slot("QModelIndex")
[docs] def _resize_first_column_to_contents(self, _index=None): self.resizeColumnToContents(0)
[docs] def remove_selected(self): """Removes items selected in the view.""" raise NotImplementedError()
[docs] def update_actions_visibility(self, item): """Updates the visible property of actions according to whether or not they apply to given item.""" raise NotImplementedError()
[docs] def connect_data_store_form(self, spine_db_editor): self._spine_db_editor = spine_db_editor self.create_context_menu() self.connect_signals()
[docs] def create_context_menu(self): """Creates a context menu for this view.""" self._menu.addAction(self._spine_db_editor.ui.actionCopy) self._menu.addAction(self._spine_db_editor.ui.actionRemove_selected)
[docs] def contextMenuEvent(self, event): """Shows context menu. Args: event (QContextMenuEvent) """ index = self.indexAt(event.pos()) if index.column() != 0: return item = index.model().item_from_index(index) self.update_actions_visibility(item) self._menu.exec_(event.globalPos())
[docs]class AlternativeScenarioTreeView(ItemTreeView): """Custom QTreeView class for the alternative scenario tree in SpineDBEditor."""
[docs] alternative_selection_changed = Signal(dict)
def __init__(self, parent): """Initialize the view.""" super().__init__(parent=parent) self._selected_alternative_ids = dict() self.setMouseTracking(True)
[docs] def mouseMoveEvent(self, e): super().mouseMoveEvent(e) index = self.indexAt(e.pos()) if not index.isValid(): return item = self.model().item_from_index(index) cursor = Qt.OpenHandCursor if item.item_type == "alternative" else Qt.ArrowCursor self.setCursor(cursor)
[docs] def connect_signals(self): """Connects signals.""" super().connect_signals() self.selectionModel().selectionChanged.connect(self._handle_selection_changed)
[docs] def _db_map_alt_ids_from_selection(self, selection): db_map_ids = {} for index in selection.indexes(): if index.column() != 0: continue item = self.model().item_from_index(index) if item.item_type == "alternative" and item.id: db_map_ids.setdefault(item.db_map, set()).add(item.id) return db_map_ids
[docs] def _db_map_scen_alt_ids_from_selection(self, selection): db_map_ids = {} for index in selection.indexes(): if index.column() != 0: continue item = self.model().item_from_index(index) if item.item_type == "scenario" and item.id: db_map_ids.setdefault(item.db_map, set()).update(item.alternative_id_list) return db_map_ids
@Slot("QItemSelection", "QItemSelection")
[docs] def _handle_selection_changed(self, selected, deselected): """Emits alternative_selection_changed with the current selection.""" selected_db_map_alt_ids = self._db_map_alt_ids_from_selection(selected) deselected_db_map_alt_ids = self._db_map_alt_ids_from_selection(deselected) selected_db_map_scen_alt_ids = self._db_map_scen_alt_ids_from_selection(selected) deselected_db_map_scen_alt_ids = self._db_map_scen_alt_ids_from_selection(deselected) # NOTE: remove deselected scenario alternatives *before* adding selected alternatives, for obvious reasons for db_map, ids in deselected_db_map_alt_ids.items(): self._selected_alternative_ids[db_map].difference_update(ids) for db_map, ids in deselected_db_map_scen_alt_ids.items(): self._selected_alternative_ids[db_map].difference_update(ids) for db_map, ids in selected_db_map_alt_ids.items(): self._selected_alternative_ids.setdefault(db_map, set()).update(ids) for db_map, ids in selected_db_map_scen_alt_ids.items(): self._selected_alternative_ids.setdefault(db_map, set()).update(ids) self.alternative_selection_changed.emit(self._selected_alternative_ids)
[docs] def remove_selected(self): """See base class.""" if not self.selectionModel().hasSelection(): return db_map_typed_data_to_rm = {} db_map_scen_alt_data = {} items = [self.model().item_from_index(index) for index in self.selectionModel().selectedIndexes()] for db_item in self.model()._invisible_root_item.children: db_map_typed_data_to_rm[db_item.db_map] = {"alternative": set(), "scenario": set()} db_map_scen_alt_data[db_item.db_map] = [] for alt_item in reversed(db_item.child(0).children[:-1]): if alt_item in items: db_map_typed_data_to_rm[db_item.db_map]["alternative"].add(alt_item.id) for scen_item in reversed(db_item.child(1).children[:-1]): if scen_item in items: db_map_typed_data_to_rm[db_item.db_map]["scenario"].add(scen_item.id) continue curr_alt_id_list = scen_item.alternative_id_list new_alt_id_list = [ id_ for alt_item, id_ in zip(scen_item.children, curr_alt_id_list) if alt_item not in items ] if new_alt_id_list != curr_alt_id_list: item = {"id": scen_item.id, "alternative_id_list": ",".join([str(id_) for id_ in new_alt_id_list])} db_map_scen_alt_data[db_item.db_map].append(item) self.model().db_mngr.set_scenario_alternatives(db_map_scen_alt_data) self.model().db_mngr.remove_items(db_map_typed_data_to_rm) self.selectionModel().clearSelection()
[docs] def update_actions_visibility(self, item):
"""See base class."""
[docs]class ParameterValueListTreeView(ItemTreeView): """Custom QTreeView class for parameter_value_list in SpineDBEditor. """ def __init__(self, parent): """Initialize the view.""" super().__init__(parent=parent) self.open_in_editor_action = None
[docs] def create_context_menu(self): """Creates a context menu for this view.""" super().create_context_menu() self.open_in_editor_action = self._menu.addAction("Open in editor...", self.open_in_editor)
[docs] def update_actions_visibility(self, item): """See base class.""" self.open_in_editor_action.setVisible(item.item_type == "value")
[docs] def open_in_editor(self): """Opens the parameter_value editor for the first selected cell.""" index = self.currentIndex() self._spine_db_editor.show_parameter_value_editor(index)
[docs] def remove_selected(self): """See base class.""" if not self.selectionModel().hasSelection(): return db_map_typed_data_to_rm = {} db_map_data_to_upd = {} items = [self.model().item_from_index(index) for index in self.selectionModel().selectedIndexes()] for db_item in self.model()._invisible_root_item.children: db_map_typed_data_to_rm[db_item.db_map] = {"parameter_value_list": set()} db_map_data_to_upd[db_item.db_map] = [] for list_item in reversed(db_item.children[:-1]): if list_item.id: if list_item in items: db_map_typed_data_to_rm[db_item.db_map]["parameter_value_list"].add(list_item.id) continue curr_value_list = list_item.value_list new_value_list = [ value for value_item, value in zip(list_item.children, curr_value_list) if value_item not in items ] if not new_value_list: db_map_typed_data_to_rm[db_item.db_map]["parameter_value_list"].add(list_item.id) continue if new_value_list != curr_value_list: item = {"id": list_item.id, "value_list": new_value_list} db_map_data_to_upd[db_item.db_map].append(item) else: # WIP lists, just remove everything selected if list_item in items: db_item.remove_children(list_item.child_number(), list_item.child_number()) continue for value_item in reversed(list_item.children[:-1]): if value_item in items: list_item.remove_children(value_item.child_number(), value_item.child_number()) self.model().db_mngr.update_parameter_value_lists(db_map_data_to_upd) self.model().db_mngr.remove_items(db_map_typed_data_to_rm) self.selectionModel().clearSelection()
[docs]class ParameterTagTreeView(ItemTreeView): """Custom QTreeView class for the parameter_tag tree in SpineDBEditor."""
[docs] tag_selection_changed = Signal(dict)
def __init__(self, parent): """Initialize the view.""" super().__init__(parent=parent) self._selected_tag_ids = dict()
[docs] def connect_signals(self): """Connects signals.""" super().connect_signals() self.selectionModel().selectionChanged.connect(self._handle_selection_changed)
[docs] def remove_selected(self): """See base class.""" if not self.selectionModel().hasSelection(): return db_map_typed_data_to_rm = {} items = [self.model().item_from_index(index) for index in self.selectionModel().selectedIndexes()] for db_item in self.model()._invisible_root_item.children: db_map_typed_data_to_rm[db_item.db_map] = {"parameter_tag": set()} for tag_item in reversed(db_item.children[:-1]): if tag_item.id and tag_item in items: db_map_typed_data_to_rm[db_item.db_map]["parameter_tag"].add(tag_item.id) self.model().db_mngr.remove_items(db_map_typed_data_to_rm) self.selectionModel().clearSelection()
[docs] def update_actions_visibility(self, item):
"""See base class.""" @Slot("QItemSelection", "QItemSelection")
[docs] def _handle_selection_changed(self, selected, deselected): """Emits tag_selection_changed with the current selection.""" selected_db_map_ids = self._db_map_tag_ids_from_selection(selected) deselected_db_map_ids = self._db_map_tag_ids_from_selection(deselected) for db_map, ids in selected_db_map_ids.items(): self._selected_tag_ids.setdefault(db_map, set()).update(ids) for db_map, ids in deselected_db_map_ids.items(): self._selected_tag_ids[db_map].difference_update(ids) self._selected_tag_ids = {db_map: ids for db_map, ids in self._selected_tag_ids.items() if ids} self.tag_selection_changed.emit(self._selected_tag_ids)
[docs] def _db_map_tag_ids_from_selection(self, selection): db_map_ids = {} for index in selection.indexes(): if index.column() != 0: continue item = self.model().item_from_index(index) if item.item_type == "parameter_tag" and item.id: db_map_ids.setdefault(item.db_map, set()).add(item.id) return db_map_ids