######################################################################################################################
# Copyright (C) 2017-2022 Spine project consortium
# Copyright Spine Toolbox contributors
# 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/>.
######################################################################################################################
"""Custom QTableView classes that support copy-paste and the like."""
from dataclasses import replace
from PySide6.QtCore import (
Qt,
Signal,
Slot,
QTimer,
QModelIndex,
QPoint,
QItemSelection,
QItemSelectionModel,
)
from PySide6.QtWidgets import QHeaderView, QTableView, QMenu, QWidget
from PySide6.QtGui import QKeySequence, QAction
from .scenario_generator import ScenarioGenerator
from ..helpers import string_to_bool
from ..mvcmodels.pivot_table_models import (
ParameterValuePivotTableModel,
ElementPivotTableModel,
IndexExpansionPivotTableModel,
ScenarioAlternativePivotTableModel,
)
from ..mvcmodels.metadata_table_model_base import Column as MetadataColumn
from ...widgets.report_plotting_failure import report_plotting_failure
from ...widgets.plot_widget import PlotWidget, prepare_plot_in_window_menu
from ...widgets.custom_qtableview import CopyPasteTableView, AutoFilterCopyPasteTableView
from ...widgets.custom_qwidgets import TitleWidgetAction
from ...plotting import (
PlottingError,
ParameterTableHeaderSection,
plot_parameter_table_selection,
plot_pivot_table_selection,
)
from ...helpers import preferred_row_height, rows_to_row_count_tuples, DB_ITEM_SEPARATOR
from .pivot_table_header_view import (
PivotTableHeaderView,
ParameterValuePivotHeaderView,
ScenarioAlternativePivotHeaderView,
)
from .tabular_view_header_widget import TabularViewHeaderWidget
from .custom_delegates import (
DatabaseNameDelegate,
ParameterDefaultValueDelegate,
ValueListDelegate,
ParameterValueDelegate,
ParameterNameDelegate,
ParameterDefinitionNameAndDescriptionDelegate,
EntityClassNameDelegate,
EntityBynameDelegate,
AlternativeNameDelegate,
MetadataDelegate,
ItemMetadataDelegate,
BooleanValueDelegate,
)
@Slot(QModelIndex, object)
[docs]def _set_data(index, new_value):
"""Updates (object or relationship) parameter_definition or value with newly edited data."""
index.model().setData(index, new_value)
[docs]class StackedTableView(AutoFilterCopyPasteTableView):
"""Base stacked view."""
[docs] _COLUMN_SIZE_HINTS = {}
[docs] _EXPECTED_COLUMN_COUNT: int = NotImplemented
def __init__(self, parent):
"""
Args:
parent (QWidget): parent widget
"""
super().__init__(parent=parent)
self._menu = QMenu(self)
self._spine_db_editor = None
header = self.horizontalHeader()
header.sectionCountChanged.connect(self._set_column_resize_modes)
[docs] def connect_spine_db_editor(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.set_external_copy_and_paste_actions(spine_db_editor.ui.actionCopy, spine_db_editor.ui.actionPaste)
self.populate_context_menu()
self.create_delegates()
self.selectionModel().selectionChanged.connect(self._refresh_copy_paste_actions)
[docs] def _make_delegate(self, column_name, delegate_class):
"""Creates a delegate for the given column and returns it.
Args:
column_name (str)
delegate_class (TableDelegate)
Returns:
TableDelegate
"""
column = self.model().header.index(column_name)
delegate = delegate_class(self._spine_db_editor, self._spine_db_editor.db_mngr)
self.setItemDelegateForColumn(column, delegate)
delegate.data_committed.connect(_set_data)
return delegate
[docs] def create_delegates(self):
"""Creates delegates for this view"""
self._make_delegate("database", DatabaseNameDelegate)
self._make_delegate("entity_class_name", EntityClassNameDelegate)
[docs] def _selected_rows_per_column(self):
"""Computes selected rows per column.
Returns:
dict: Mapping columns to selected rows in that column.
"""
selection = self.selectionModel().selection()
if not selection:
return {}
v_header = self.verticalHeader()
h_header = self.horizontalHeader()
rows_per_column = {}
for rng in sorted(selection, key=lambda x: h_header.visualIndex(x.left())):
for j in range(rng.left(), rng.right() + 1):
if h_header.isSectionHidden(j):
continue
rows = rows_per_column.setdefault(j, set())
for i in range(rng.top(), rng.bottom() + 1):
if v_header.isSectionHidden(i):
continue
rows.add(i)
return rows_per_column
@Slot(bool)
[docs] def filter_by_selection(self, checked=False):
rows_per_column = self._selected_rows_per_column()
self.model().filter_by(rows_per_column)
@Slot(bool)
[docs] def filter_excluding_selection(self, checked=False):
rows_per_column = self._selected_rows_per_column()
self.model().filter_excluding(rows_per_column)
[docs] def remove_selected(self):
"""Removes selected indexes."""
selection = self.selectionModel().selection()
rows = []
while not selection.isEmpty():
current = selection.takeAt(0)
top = current.top()
bottom = current.bottom()
rows += range(top, bottom + 1)
# Get data grouped by db_map
db_map_typed_data = {}
model = self.model()
empty_model = model.empty_model
for row in sorted(rows, reverse=True):
sub_model = model.sub_model_at_row(row)
if sub_model is empty_model:
sub_row = model.sub_model_row(row)
sub_model.removeRow(sub_row)
continue
db_map = sub_model.db_map
id_ = model.item_at_row(row)
db_map_typed_data.setdefault(db_map, {}).setdefault(model.item_type, []).append(id_)
model.db_mngr.remove_items(db_map_typed_data)
self.selectionModel().clearSelection()
@Slot(QModelIndex, QModelIndex)
[docs] def _refresh_copy_paste_actions(self, _, __):
"""Enables or disables copy and paste actions."""
self._spine_db_editor.refresh_copy_paste_actions()
[docs] def _initial_column_size(self, column):
label = (
self.horizontalHeader().model().headerData(column, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole)
)
return self._COLUMN_SIZE_HINTS.get(label, 100)
@Slot(int, int)
[docs] def _set_column_resize_modes(self, old_column_count, new_column_count):
if new_column_count != self._EXPECTED_COLUMN_COUNT:
return
for column in range(new_column_count):
width = self._initial_column_size(column)
self.horizontalHeader().resizeSection(column, width)
[docs]class ParameterTableView(StackedTableView):
[docs] value_column_header: str = NotImplemented
"""Either "default_value" or "value". Used to identify the value column for advanced editing and plotting."""
def __init__(self, parent):
"""
Args:
parent (QWidget): parent widget
"""
super().__init__(parent=parent)
self._open_in_editor_action = None
self._plot_action = None
self._plot_separator = None
self.pinned_values = []
[docs] def open_in_editor(self):
"""Opens the current index in a parameter_value editor using the connected Spine db editor."""
index = self.currentIndex()
self._spine_db_editor.show_parameter_value_editor(index)
@Slot(bool)
[docs] def plot(self, checked=False):
"""Plots current index."""
selection = self.selectedIndexes()
try:
plot_widget = self._plot_selection(selection)
except PlottingError as error:
report_plotting_failure(error, self._spine_db_editor)
else:
plot_widget.use_as_window(self.window(), self.value_column_header)
plot_widget.show()
[docs] def _plot_selection(self, selection, plot_widget=None):
"""Adds selected indexes to existing plot or creates a new plot window.
Args:
selection (Iterable of QModelIndex): a list of QModelIndex objects for plotting
plot_widget (PlotWidget, optional): an existing plot widget to draw into or None to create a new widget
Returns:
PlotWidget: a PlotWidget object
"""
raise NotImplementedError()
@Slot(QAction)
[docs] def plot_in_window(self, action):
"""Plots current index in the window given by action's name."""
plot_window_name = action.text()
plot_window = PlotWidget.plot_windows.get(plot_window_name)
selection = self.selectedIndexes()
try:
self._plot_selection(selection, plot_window)
plot_window.raise_()
except PlottingError as error:
report_plotting_failure(error, self._spine_db_editor)
[docs]class ParameterDefinitionTableView(ParameterTableView):
[docs] value_column_header = "default_value"
[docs] _EXPECTED_COLUMN_COUNT = 6
[docs] _COLUMN_SIZE_HINTS = {"entity_class_name": 200, "parameter_name": 125, "list_value_name": 125, "description": 250}
[docs] def create_delegates(self):
super().create_delegates()
self._make_delegate("value_list_name", ValueListDelegate)
self._make_delegate("parameter_name", ParameterDefinitionNameAndDescriptionDelegate)
self._make_delegate("description", ParameterDefinitionNameAndDescriptionDelegate)
delegate = self._make_delegate("default_value", ParameterDefaultValueDelegate)
delegate.parameter_value_editor_requested.connect(self._spine_db_editor.show_parameter_value_editor)
[docs] def _plot_selection(self, selection, plot_widget=None):
"""See base class"""
header_sections = [
ParameterTableHeaderSection(label) for label in ("database", "entity_class_name", "parameter_name")
]
return plot_parameter_table_selection(
self.model(), selection, header_sections, self.value_column_header, plot_widget
)
[docs]class ParameterValueTableView(ParameterTableView):
[docs] value_column_header = "value"
[docs] _COLUMN_SIZE_HINTS = {
"entity_class_name": 200,
"entity_byname": 200,
"parameter_name": 125,
"alternative_name": 125,
}
[docs] _EXPECTED_COLUMN_COUNT = 6
[docs] def connect_spine_db_editor(self, spine_db_editor):
super().connect_spine_db_editor(spine_db_editor)
self.selectionModel().selectionChanged.connect(self._update_pinned_values)
[docs] def create_delegates(self):
super().create_delegates()
self._make_delegate("parameter_name", ParameterNameDelegate)
self._make_delegate("alternative_name", AlternativeNameDelegate)
delegate = self._make_delegate("value", ParameterValueDelegate)
delegate.parameter_value_editor_requested.connect(self._spine_db_editor.show_parameter_value_editor)
delegate = self._make_delegate("entity_byname", EntityBynameDelegate)
delegate.element_name_list_editor_requested.connect(self._spine_db_editor.show_element_name_list_editor)
[docs] def _update_pinned_values(self, _selected, _deselected):
row_pinned_value_iter = ((index.row(), self._make_pinned_value(index)) for index in self.selectedIndexes())
self.pinned_values = list(
{row: pinned_value for row, pinned_value in row_pinned_value_iter if pinned_value is not None}.values()
)
self._spine_db_editor.emit_pinned_values_updated()
[docs] def _make_pinned_value(self, index):
db_map, _ = self.model().db_map_id(index)
if db_map is None:
return None
db_item = self.model().db_item(index)
if db_item is None:
return None
return (db_map.db_url, {f: db_item[f] for f in self._pk_fields})
@property
[docs] def _pk_fields(self):
return "entity_class_name", "entity_byname", "parameter_name", "alternative_name"
[docs] def _plot_selection(self, selection, plot_widget=None):
"""See base class."""
header_sections = [ParameterTableHeaderSection(label) for label in ("database",) + self._pk_fields]
for i, section in enumerate(header_sections):
if section.label == "entity_byname":
header_sections[i] = replace(section, separator=DB_ITEM_SEPARATOR)
break
return plot_parameter_table_selection(
self.model(), selection, header_sections, self.value_column_header, plot_widget
)
[docs]class EntityAlternativeTableView(StackedTableView):
"""Visualize entities and their alternatives."""
[docs] _EXPECTED_COLUMN_COUNT = 5
[docs] _COLUMN_SIZE_HINTS = {"entity_class_name": 200, "entity_byname": 200, "alternative_name": 125}
def __init__(self, parent):
super().__init__(parent)
self.set_column_converter_for_pasting("active", string_to_bool)
[docs] def create_delegates(self):
super().create_delegates()
delegate = self._make_delegate("entity_byname", EntityBynameDelegate)
delegate.element_name_list_editor_requested.connect(self._spine_db_editor.show_element_name_list_editor)
self._make_delegate("alternative_name", AlternativeNameDelegate)
self._make_delegate("active", BooleanValueDelegate)
[docs]class PivotTableView(CopyPasteTableView):
"""Custom QTableView class with pivot capabilities.
Uses 'contexts' to provide different UI elements (table headers, context menus,...) depending on what
data the pivot table currently contains.
"""
[docs] class _ContextBase:
"""Base class for pivot table view's contexts."""
[docs] _REMOVE_ENTITY = "Remove entities"
[docs] _REMOVE_PARAMETER = "Remove parameter definitions"
[docs] _REMOVE_ALTERNATIVE = "Remove alternatives"
[docs] _REMOVE_SCENARIO = "Remove scenarios"
[docs] _DUPLICATE_SCENARIO = "Duplicate scenario"
def __init__(self, view, db_editor, horizontal_header, vertical_header):
"""
Args:
view (PivotTableView): parent view
db_editor (SpineDBEditor): database editor
horizontal_header (QHeaderView): horizontal header
vertical_header (QHeaderView): vertical header
"""
self._view = view
self._db_editor = db_editor
self._selected_alternative_indexes = []
self._header_selection_lists = {"alternative": self._selected_alternative_indexes}
self._remove_alternatives_action = None
self._menu = QMenu(self._view)
self.populate_context_menu()
horizontal_header.setResizeContentsPrecision(self._db_editor.visible_rows)
vertical_header.setDefaultSectionSize(preferred_row_height(self._view))
self._view.setHorizontalHeader(horizontal_header)
self._view.setVerticalHeader(vertical_header)
self._view.header_changed.emit()
[docs] def _clear_selection_lists(self):
"""Clears cached selected index lists."""
self._selected_alternative_indexes.clear()
[docs] def _refresh_selected_indexes(self):
"""Caches selected index lists."""
self._clear_selection_lists()
for index in map(self._view.model().mapToSource, self._view.selectedIndexes()):
self._to_selection_lists(index)
[docs] def remove_alternatives(self):
"""Removes selected alternatives from the database."""
db_map_typed_data = {}
source_model = self._view.source_model
for index in self._selected_alternative_indexes:
db_map, id_ = source_model._header_id(index)
db_map_typed_data.setdefault(db_map, {}).setdefault("alternative", set()).add(id_)
self._db_editor.db_mngr.remove_items(db_map_typed_data)
@Slot(QPoint)
[docs] def _to_selection_lists(self, index):
"""Caches given index to corresponding selected index list.
Args:
index (QModelIndex): index to cache
"""
if self._view.source_model.index_in_headers(index):
top_left_id = self._view.source_model.top_left_id(index)
header_type = self._view.source_model.top_left_headers[top_left_id].header_type
try:
self._header_selection_lists[header_type].append(index)
except KeyError:
pass
[docs] def _update_actions_availability(self):
"""Enables/disables context menu entries before the menu is shown."""
raise NotImplementedError()
[docs] class _EntityContextBase(_ContextBase):
"""Base class for contexts that contain entities and entity classes."""
def __init__(self, view, db_editor, horizontal_header, vertical_header):
"""
Args:
view (PivotTableView): parent view
db_editor (SpineDBEditor): database editor
horizontal_header (QHeaderView): horizontal header
vertical_header (QHeaderView): vertical header
"""
self._selected_entity_indexes = []
super().__init__(view, db_editor, horizontal_header, vertical_header)
self._header_selection_lists["entity"] = self._selected_entity_indexes
[docs] def _clear_selection_lists(self):
"""See base class."""
self._selected_entity_indexes.clear()
super()._clear_selection_lists()
[docs] def remove_entities(self):
"""Removes selected entities from the database."""
db_map_typed_data = {
db_map: {"entity": entity_ids}
for db_map, entity_ids in self._view.source_model.db_map_entity_ids(
self._selected_entity_indexes
).items()
}
self._db_editor.db_mngr.remove_items(db_map_typed_data)
[docs] def _update_actions_availability(self):
"""See base class."""
raise NotImplementedError()
[docs] class _ParameterValueContext(_EntityContextBase):
"""Context for showing parameter values in the pivot table."""
def __init__(self, view, db_editor):
"""
Args:
view (PivotTableView): parent view
db_editor (SpineDBEditor): database editor
"""
self._selected_parameter_indexes = list()
self._selected_value_indexes = list()
self._open_in_editor_action = None
self._remove_values_action = None
self._remove_parameters_action = None
self._remove_entities_action = None
self._plot_action = None
self._plot_in_window_menu = None
horizontal_header = ParameterValuePivotHeaderView(Qt.Orientation.Horizontal, "columns", view)
vertical_header = ParameterValuePivotHeaderView(Qt.Orientation.Vertical, "rows", view)
super().__init__(view, db_editor, horizontal_header, vertical_header)
self._header_selection_lists["parameter"] = self._selected_parameter_indexes
[docs] def _clear_selection_lists(self):
"""See base class."""
self._selected_parameter_indexes.clear()
self._selected_value_indexes.clear()
super()._clear_selection_lists()
[docs] def open_in_editor(self):
"""Opens the parameter value editor for the first selected cell."""
index = self._selected_value_indexes[0]
self._db_editor.show_parameter_value_editor(index)
[docs] def plot(self):
"""Plots the selected cells."""
selected_indexes = self._view.selectedIndexes()
model = self._view.model()
try:
plot_window = plot_pivot_table_selection(model, selected_indexes)
except PlottingError as error:
report_plotting_failure(error, self._view)
return
source_model = model.sourceModel()
plotted_column_names = {
source_model.column_name(index.column())
for index in selected_indexes
if source_model.index_in_data(model.mapToSource(index))
or source_model.column_is_index_column(model.mapToSource(index).column())
}
plot_window.use_as_window(self._view, ", ".join(plotted_column_names))
plot_window.show()
@Slot(QAction)
[docs] def _plot_in_window(self, action):
"""Plots the selected cells in an existing window."""
window_id = action.text()
plot_window = PlotWidget.plot_windows.get(window_id)
if plot_window is None:
self.plot()
return
selected_indexes = self._view.selectedIndexes()
try:
plot_pivot_table_selection(self._view.model(), selected_indexes, plot_window)
plot_window.raise_()
except PlottingError as error:
report_plotting_failure(error, self._view)
[docs] def remove_parameters(self):
"""Removes selected parameter definitions from the database."""
db_map_typed_data = {}
source_model = self._view.source_model
for index in self._selected_parameter_indexes:
db_map, id_ = source_model._header_id(index)
db_map_typed_data.setdefault(db_map, {}).setdefault("parameter_definition", set()).add(id_)
self._db_editor.db_mngr.remove_items(db_map_typed_data)
[docs] def remove_values(self):
"""Removes selected parameter values from the database."""
row_mask = set()
column_mask = set()
source_model = self._view.source_model
for index in self._selected_value_indexes:
row, column = source_model.map_to_pivot(index)
row_mask.add(row)
column_mask.add(column)
data = source_model.model.get_pivoted_data(row_mask, column_mask)
items = (item for row in data for item in row)
db_map_typed_data = {}
for item in items:
if item is None:
continue
db_map, id_ = item
db_map_typed_data.setdefault(db_map, {}).setdefault("parameter_value", set()).add(id_)
self._db_editor.db_mngr.remove_items(db_map_typed_data)
@Slot(QPoint)
[docs] def _to_selection_lists(self, index):
"""See base class."""
if self._view.source_model.index_in_data(index):
self._selected_value_indexes.append(index)
else:
super()._to_selection_lists(index)
[docs] def _update_actions_availability(self):
"""See base class."""
is_single_editable_selection = (
len(self._selected_value_indexes) == 1
and (self._selected_value_indexes[0].flags() & Qt.ItemFlag.ItemIsEditable) != Qt.ItemFlag.NoItemFlags
)
self._open_in_editor_action.setEnabled(is_single_editable_selection)
has_selection = bool(self._selected_value_indexes)
self._plot_action.setEnabled(has_selection)
self._remove_values_action.setEnabled(has_selection)
self._remove_parameters_action.setEnabled(has_selection)
self._remove_entities_action.setEnabled(has_selection)
self._remove_alternatives_action.setEnabled(has_selection)
[docs] class _IndexExpansionContext(_ParameterValueContext):
"""Context for expanded parameter values"""
[docs] class _ElementContext(_EntityContextBase):
"""Context for presenting relationships in the pivot table."""
def __init__(self, view, db_editor):
"""
Args:
view (PivotTableView): parent view
db_editor (SpineDBEditor): database editor
"""
horizontal_header = PivotTableHeaderView(Qt.Orientation.Horizontal, "columns", view)
vertical_header = PivotTableHeaderView(Qt.Orientation.Vertical, "rows", view)
super().__init__(view, db_editor, horizontal_header, vertical_header)
[docs] def _update_actions_availability(self):
"""See base class."""
[docs] class _ScenarioAlternativeContext(_ContextBase):
"""Context for presenting scenarios and alternatives"""
def __init__(self, view, db_editor):
"""
Args:
view (PivotTableView): parent view
db_editor (SpineDBEditor): database editor
"""
self._selected_scenario_indexes = list()
self._selected_scenario_alternative_indexes = list()
self._generate_scenarios_action = None
self._toggle_alternatives_checked = QAction("Check/uncheck selected")
self._toggle_alternatives_checked.triggered.connect(self._toggle_checked_state)
self._remove_scenarios_action = None
self._duplicate_scenario_action = None
horizontal_header = ScenarioAlternativePivotHeaderView(Qt.Orientation.Horizontal, "columns", view)
horizontal_header.context_menu_requested.connect(self.show_context_menu)
vertical_header = ScenarioAlternativePivotHeaderView(Qt.Orientation.Vertical, "rows", view)
vertical_header.context_menu_requested.connect(self.show_context_menu)
super().__init__(view, db_editor, horizontal_header, vertical_header)
self._header_selection_lists["scenario"] = self._selected_scenario_indexes
[docs] def _clear_selection_lists(self):
"""See base class."""
self._selected_scenario_indexes.clear()
self._selected_scenario_alternative_indexes.clear()
super()._clear_selection_lists()
[docs] def remove_scenarios(self):
"""Removes selected scenarios from the database."""
db_map_typed_data = {}
source_model = self._view.source_model
for index in self._selected_scenario_indexes:
db_map, id_ = source_model._header_id(index)
db_map_typed_data.setdefault(db_map, {}).setdefault("scenario", set()).add(id_)
self._db_editor.db_mngr.remove_items(db_map_typed_data)
self._db_editor.do_reload_pivot_table()
[docs] def duplicate_scenario(self):
"""Duplicates current scenario in the database."""
index = self._selected_scenario_indexes[0]
db_map, scen_id = self._view.source_model._header_id(index)
self._db_editor.duplicate_scenario(db_map, scen_id)
[docs] def _to_selection_lists(self, index):
"""See base class."""
if self._view.source_model.index_in_data(index):
self._selected_scenario_alternative_indexes.append(index)
else:
super()._to_selection_lists(index)
[docs] def _update_actions_availability(self):
"""See base class."""
self._generate_scenarios_action.setEnabled(bool(self._selected_alternative_indexes))
self._remove_alternatives_action.setEnabled(bool(self._selected_alternative_indexes))
self._remove_scenarios_action.setEnabled(bool(self._selected_scenario_indexes))
self._duplicate_scenario_action.setEnabled(len(self._selected_scenario_indexes) == 1)
[docs] def _open_scenario_generator(self):
"""Opens the scenario generator dialog."""
source_model = self._view.source_model
db_mngr = self._db_editor.db_mngr
chosen_db_map = None
alternatives = list()
for index in self._selected_alternative_indexes:
header_id = source_model._header_id(index)
db_map, id_ = header_id
if chosen_db_map is None:
chosen_db_map = db_map
elif db_map is not chosen_db_map:
continue
item = db_mngr.get_item(db_map, "alternative", id_)
alternatives.append(item)
generator = ScenarioGenerator(self._view, chosen_db_map, alternatives, self._db_editor)
generator.show()
@Slot()
[docs] def _toggle_checked_state(self):
"""Toggles the checked state of selected alternatives."""
source_model = self._view.source_model
selected = [source_model.data(index) for index in self._selected_scenario_alternative_indexes]
checked = len(selected) * [not all(selected)]
source_model.batch_set_data(self._selected_scenario_alternative_indexes, checked)
def __init__(self, parent=None):
"""
Args:
parent (QWidget, optional): parent widget
"""
super().__init__(parent)
# NOTE: order of creation of header tables is important for them to stack properly
self._left_header_table = CopyPasteTableView(self)
self._top_header_table = CopyPasteTableView(self)
self._top_left_header_table = CopyPasteTableView(self)
self._left_header_table.setObjectName("left")
self._top_header_table.setObjectName("top")
self._top_left_header_table.setObjectName("top-left")
self._spine_db_editor = None
self._context = None
self._fetch_more_timer = QTimer(self)
self._fetch_more_timer.setSingleShot(True)
self._fetch_more_timer.setInterval(100)
self._fetch_more_timer.timeout.connect(self._fetch_more_visible)
self._left_header_table.verticalScrollBar().valueChanged.connect(self.verticalScrollBar().setValue)
self.verticalScrollBar().valueChanged.connect(self._left_header_table.verticalScrollBar().setValue)
self._top_header_table.horizontalScrollBar().valueChanged.connect(self.horizontalScrollBar().setValue)
self.horizontalScrollBar().valueChanged.connect(self._top_header_table.horizontalScrollBar().setValue)
# NOTE: order of the iteration below is important for calls to stackUnder
for header_table in (self._top_left_header_table, self._top_header_table, self._left_header_table):
header_table.setFocusPolicy(Qt.NoFocus)
header_table.setStyleSheet(self.styleSheet())
header_table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
header_table.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
header_table.show()
header_table.verticalHeader().hide()
header_table.horizontalHeader().hide()
header_table.setHorizontalScrollMode(QTableView.ScrollMode.ScrollPerPixel)
header_table.setVerticalScrollMode(QTableView.ScrollMode.ScrollPerPixel)
header_table.setStyleSheet("QTableView { border: none;}")
self.viewport().stackUnder(header_table)
for header_table in (self._top_header_table, self._left_header_table):
header_table.setAttribute(Qt.WA_TransparentForMouseEvents)
header = self.horizontalHeader()
header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
@property
[docs] def source_model(self):
return self.model().sourceModel()
@property
[docs] def db_mngr(self):
return self.source_model.db_mngr
[docs] def connect_spine_db_editor(self, spine_db_editor):
self._spine_db_editor = spine_db_editor
self.set_external_copy_and_paste_actions(spine_db_editor.ui.actionCopy, spine_db_editor.ui.actionPaste)
self._spine_db_editor.pivot_table_proxy.sourceModelChanged.connect(self._change_context)
self.selectionModel().selectionChanged.connect(self._refresh_copy_paste_actions)
@Slot()
[docs] def _change_context(self):
"""Changes the UI engine according to pivot model type."""
model = self._spine_db_editor.pivot_table_proxy.sourceModel()
if isinstance(model, ParameterValuePivotTableModel):
self._context = self._ParameterValueContext(self, self._spine_db_editor)
elif isinstance(model, ElementPivotTableModel):
self._context = self._ElementContext(self, self._spine_db_editor)
elif isinstance(model, IndexExpansionPivotTableModel):
self._context = self._IndexExpansionContext(self, self._spine_db_editor)
elif isinstance(model, ScenarioAlternativePivotTableModel):
self._context = self._ScenarioAlternativeContext(self, self._spine_db_editor)
else:
raise RuntimeError(f"Unknown pivot table model: {type(model).__name__}")
[docs] def setModel(self, model):
old_model = self.model()
if old_model:
old_model.model_data_changed.disconnect(self._fetch_more_timer.start)
old_model.model_data_changed.disconnect(self._update_header_tables)
old_model.modelReset.disconnect(self._update_header_tables)
self.selectionModel().selectionChanged.disconnect(self._synch_selection_with_header_tables)
super().setModel(model)
for header_table in (self._left_header_table, self._top_header_table, self._top_left_header_table):
header_table.setModel(model)
model.model_data_changed.connect(self._fetch_more_timer.start)
model.model_data_changed.connect(self._update_header_tables)
model.modelReset.connect(self._update_header_tables)
self.selectionModel().selectionChanged.connect(self._synch_selection_with_header_tables)
@Slot(QItemSelection, QItemSelection)
[docs] def resizeEvent(self, ev):
super().resizeEvent(ev)
self._update_header_tables_geometry()
[docs] def sizeHintForColumn(self, column):
base_size = super().sizeHintForColumn(column)
proxy_model = self.model()
source_model = proxy_model.sourceModel()
if column >= source_model.headerColumnCount():
return base_size
max_width = base_size
for row in range(source_model.headerRowCount()):
source_index = proxy_model.index(row, column)
index_widget = self._top_left_header_table.indexWidget(source_index)
if index_widget is None:
continue
max_width = max(max_width, index_widget.sizeHint().width())
return max_width
[docs] def _fetch_more_visible(self):
model = self.model()
scrollbar = self.verticalScrollBar()
scrollbar_at_max = scrollbar.value() == scrollbar.maximum()
if scrollbar_at_max and model.canFetchMore(QModelIndex()):
model.fetchMore(QModelIndex())
@Slot(int, int, int)
[docs] def _update_section_width(self, logical_index, _old_size, new_size):
for header_table in (self._left_header_table, self._top_header_table, self._top_left_header_table):
header_table.setColumnWidth(logical_index, new_size)
self._update_header_tables_geometry()
@Slot(int, int, int)
[docs] def _update_section_height(self, logical_index, _old_size, new_size):
for header_table in (self._left_header_table, self._top_header_table, self._top_left_header_table):
header_table.setRowHeight(logical_index, new_size)
self._update_header_tables_geometry()
@Slot(QModelIndex, QModelIndex)
[docs] def _refresh_copy_paste_actions(self, _, __):
self._spine_db_editor.refresh_copy_paste_actions()
[docs]class FrozenTableView(QTableView):
def __init__(self, parent=None):
"""
Args:
parent (QWidget): parent widget
"""
super().__init__(parent)
self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
@property
[docs] def area(self):
return "frozen"
[docs] def dragEnterEvent(self, event):
if isinstance(event.source(), TabularViewHeaderWidget):
event.accept()
[docs] def dragMoveEvent(self, event):
if isinstance(event.source(), TabularViewHeaderWidget):
event.accept()
[docs] def dropEvent(self, event):
self.header_dropped.emit(event.source(), self)