Source code for widgets.custom_qtableview

######################################################################################################################
# Copyright (C) 2017 - 2019 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/>.
######################################################################################################################

"""
Custom QTableView classes that support copy-paste and the like.

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

import csv
import io
import locale
import numpy as np
from PySide2.QtWidgets import QTableView, QApplication, QAbstractItemView, QMenu, QLineEdit, QWidgetAction
from PySide2.QtCore import Qt, Signal, Slot, QItemSelectionModel, QPoint, QSortFilterProxyModel
from PySide2.QtGui import QKeySequence
from models import TableModel, MinimalTableModel


[docs]class CopyPasteTableView(QTableView): """Custom QTableView class with copy and paste methods."""
[docs] def keyPressEvent(self, event): """Copy and paste to and from clipboard in Excel-like format.""" if event.matches(QKeySequence.Copy): if not self.copy(): super().keyPressEvent(event) elif event.matches(QKeySequence.Paste): if not self.paste(): super().keyPressEvent(event) else: super().keyPressEvent(event)
[docs] def copy(self): """Copy current selection to clipboard in excel format.""" selection = self.selectionModel().selection() if not selection: return False v_header = self.verticalHeader() h_header = self.horizontalHeader() row_dict = {} for rng in sorted(selection, key=lambda x: h_header.visualIndex(x.left())): for i in range(rng.top(), rng.bottom() + 1): if v_header.isSectionHidden(i): continue row = row_dict.setdefault(i, []) for j in range(rng.left(), rng.right() + 1): if h_header.isSectionHidden(j): continue data = self.model().index(i, j).data(Qt.EditRole) if data is not None: try: number = float(data) str_data = locale.str(number) except ValueError: str_data = str(data) else: str_data = "" row.append(str_data) with io.StringIO() as output: writer = csv.writer(output, delimiter='\t') for key in sorted(row_dict): writer.writerow(row_dict[key]) QApplication.clipboard().setText(output.getvalue()) return True
[docs] def canPaste(self): # pylint: disable=no-self-use return True
[docs] def paste(self): """Paste data from clipboard.""" selection = self.selectionModel().selection() if len(selection.indexes()) > 1: return self.paste_on_selection() return self.paste_normal()
@staticmethod
[docs] def _read_pasted_text(text): """ Parses a tab separated CSV text table. Args: text (str): a CSV formatted table Returns: a list of rows """ with io.StringIO(text) as input_stream: reader = csv.reader(input_stream, delimiter='\t') rows = list() for row in reader: rows.append([locale.delocalize(element) for element in row]) return rows
[docs] def paste_on_selection(self): """Paste clipboard data on selection, but not beyond. If data is smaller than selection, repeat data to fit selection.""" text = QApplication.clipboard().text() if not text: return False data = self._read_pasted_text(text) if not data: return False selection = self.selectionModel().selection() if selection.isEmpty(): return False indexes = list() values = list() is_row_hidden = self.verticalHeader().isSectionHidden rows = [x for r in selection for x in range(r.top(), r.bottom() + 1) if not is_row_hidden(x)] is_column_hidden = self.horizontalHeader().isSectionHidden columns = [x for r in selection for x in range(r.left(), r.right() + 1) if not is_column_hidden(x)] model_index = self.model().index for row in rows: for column in columns: index = model_index(row, column) if index.flags() & Qt.ItemIsEditable: i = (row - rows[0]) % len(data) j = (column - columns[0]) % len(data[i]) value = data[i][j] indexes.append(index) values.append(value) self.model().batch_set_data(indexes, values) return True
[docs] def paste_normal(self): """Paste clipboard data, overwriting cells if needed""" text = QApplication.clipboard().text().strip() if not text: return False data = self._read_pasted_text(text) if not data: return False current = self.currentIndex() if not current.isValid(): return False indexes = list() values = list() row = current.row() rows = [] rows_append = rows.append is_row_hidden = self.verticalHeader().isSectionHidden for _ in range(len(data)): while is_row_hidden(row): row += 1 rows_append(row) row += 1 column = current.column() visual_column = self.horizontalHeader().visualIndex(column) columns = [] columns_append = columns.append h = self.horizontalHeader() is_visual_column_hidden = lambda x: h.isSectionHidden(h.logicalIndex(x)) for _ in range(len(data[0])): while is_visual_column_hidden(visual_column): visual_column += 1 columns_append(h.logicalIndex(visual_column)) visual_column += 1 # Insert extra rows if needed: last_row = max(rows) row_count = self.model().rowCount() if last_row >= row_count: self.model().insertRows(row_count, last_row - row_count + 1) # Insert extra columns if needed: last_column = max(columns) column_count = self.model().columnCount() if last_column >= column_count: self.model().insertColumns(column_count, last_column - column_count + 1) model_index = self.model().index for i, row in enumerate(rows): try: line = data[i] except IndexError: break for j, column in enumerate(columns): try: value = line[j] except IndexError: break index = model_index(row, column) if index.flags() & Qt.ItemIsEditable: indexes.append(index) values.append(value) self.model().batch_set_data(indexes, values) return True
[docs]class AutoFilterMenu(QMenu): """A widget to show the auto filter 'menu'. Attributes: parent (QTableView): the parent widget. """
[docs] asc_sort_triggered = Signal(name="asc_sort_triggered")
[docs] desc_sort_triggered = Signal(name="desc_sort_triggered")
[docs] filter_triggered = Signal(name="filter_triggered")
def __init__(self, parent): """Initialize class.""" super().__init__(parent) self.row_is_accepted = [] self.unchecked_values = dict() self.model = MinimalTableModel(self) self.model.data = self._model_data self.model.flags = self._model_flags self.proxy_model = QSortFilterProxyModel(self) self.proxy_model.setFilterKeyColumn(1) self.proxy_model.setSourceModel(self.model) self.proxy_model.filterAcceptsRow = self._proxy_model_filter_accepts_row self.text_filter = QLineEdit(self) self.text_filter.setPlaceholderText("Search...") self.text_filter.setClearButtonEnabled(True) self.view = QTableView(self) self.view.setModel(self.proxy_model) self.view.verticalHeader().hide() self.view.horizontalHeader().hide() self.view.setShowGrid(False) self.view.setMouseTracking(True) self.view.entered.connect(self._handle_view_entered) self.view.clicked.connect(self._handle_view_clicked) self.view.leaveEvent = self._view_leave_event self.view.keyPressEvent = self._view_key_press_event text_filter_action = QWidgetAction(self) text_filter_action.setDefaultWidget(self.text_filter) view_action = QWidgetAction(self) view_action.setDefaultWidget(self.view) self.addAction(text_filter_action) self.addAction(view_action) ok_action = self.addAction("Ok") # pylint: disable=unnecessary-lambda self.text_filter.textEdited.connect(lambda x: self.proxy_model.setFilterRegExp(x)) ok_action.triggered.connect(self._handle_ok_action_triggered)
[docs] def _model_flags(self, index): # pylint: disable=no-self-use """Return no item flags.""" return ~Qt.ItemIsEditable
[docs] def _model_data(self, index, role=Qt.DisplayRole): """Read checked state from first column.""" if role == Qt.CheckStateRole: checked = self.model._main_data[index.row()][0] if checked is None: return Qt.PartiallyChecked if checked is True: return Qt.Checked return Qt.Unchecked return MinimalTableModel.data(self.model, index, role)
[docs] def _proxy_model_filter_accepts_row(self, source_row, source_parent): """Overridden method to always accept first row. """ if source_row == 0: return True result = QSortFilterProxyModel.filterAcceptsRow(self.proxy_model, source_row, source_parent) self.row_is_accepted[source_row] = result return result
@Slot("QModelIndex", name="_handle_view_entered")
[docs] def _handle_view_entered(self, index): """Highlight current row.""" self.view.selectionModel().select(index, QItemSelectionModel.ClearAndSelect)
[docs] def _view_key_press_event(self, event): QTableView.keyPressEvent(self.view, event) if event.key() == Qt.Key_Space: index = self.view.currentIndex() self.toggle_checked_state(index)
@Slot("QModelIndex", name="_handle_view_clicked")
[docs] def _handle_view_clicked(self, index): self.toggle_checked_state(index)
[docs] def toggle_checked_state(self, checked_index): """Toggle checked state.""" index = self.proxy_model.index(checked_index.row(), 0) checked = index.data(Qt.EditRole) row_count = self.proxy_model.rowCount() if index.row() == 0: # All row all_checked = checked in (None, False) for row in range(0, row_count): self.proxy_model.setData(self.proxy_model.index(row, 0), all_checked) self.proxy_model.dataChanged.emit(self.proxy_model.index(0, 1), self.proxy_model.index(row_count - 1, 1)) else: # Data row self.proxy_model.setData(index, not checked) self.proxy_model.dataChanged.emit(checked_index, checked_index) self.set_data_for_all_index()
[docs] def _view_leave_event(self, event): """Clear selection.""" self.view.selectionModel().clearSelection() event.accept()
[docs] def set_data_for_all_index(self): """Set data for 'all' index based on data from all other indexes.""" all_index = self.proxy_model.index(0, 0) true_count = 0 row_count = self.proxy_model.rowCount() for row in range(1, row_count): if self.proxy_model.index(row, 0).data(): true_count += 1 if true_count == row_count - 1: self.proxy_model.setData(all_index, True) elif true_count == 0: self.proxy_model.setData(all_index, False) else: self.proxy_model.setData(all_index, None) index = self.proxy_model.index(0, 1) self.proxy_model.dataChanged.emit(index, index)
@Slot("bool", name="_handle_ok_action_triggered")
[docs] def _handle_ok_action_triggered(self, checked=False): """Called when user presses Ok.""" self.unchecked_values = dict() for row in range(1, self.model.rowCount()): checked, value, object_class_id_set = self.model._main_data[row] if not self.row_is_accepted[row] or not checked: for object_class_id in object_class_id_set: self.unchecked_values.setdefault(object_class_id, set()).add(value) self.filter_triggered.emit()
[docs] def set_values(self, values): """Set values to show in the 'menu'.""" self.row_is_accepted = [True for _ in range(len(values) + 1)] self.model.reset_model([[None, "(Select All)", ""]] + values) self.set_data_for_all_index() self.view.horizontalHeader().hideSection(0) # Column 0 holds the checked state self.view.horizontalHeader().hideSection(2) # Column 2 holds the (cls_id_set) self.proxy_model.setFilterRegExp("")
[docs] def popup(self, pos, width=0, at_action=None): super().popup(pos, at_action) self.text_filter.clear() self.text_filter.setFocus() self.view.horizontalHeader().setMinimumSectionSize(0) self.view.resizeColumnToContents(1) table_width = self.view.horizontalHeader().sectionSize(1) + 2 width = max(table_width, width) self.view.horizontalHeader().setMinimumSectionSize(width) parent_section_height = self.parent().verticalHeader().defaultSectionSize() self.view.verticalHeader().setDefaultSectionSize(parent_section_height) # if self.view.verticalScrollBar().isVisible(): # width += qApp.style().pixelMetric(QStyle.PM_ScrollBarExtent) self.setFixedWidth(width)
[docs]class AutoFilterCopyPasteTableView(CopyPasteTableView): """Custom QTableView class with autofilter functionality. Attributes: parent (QWidget): The parent of this view """
[docs] filter_changed = Signal("QObject", "int", "QStringList", name="filter_changed")
def __init__(self, parent): """Initialize the class.""" super().__init__(parent=parent) self.auto_filter_column = None self.auto_filter_menu = AutoFilterMenu(self) self.auto_filter_menu.asc_sort_triggered.connect(self.sort_model_ascending) self.auto_filter_menu.desc_sort_triggered.connect(self.sort_model_descending) self.auto_filter_menu.filter_triggered.connect(self.update_auto_filter)
[docs] def keyPressEvent(self, event): if event.modifiers() == Qt.AltModifier and event.key() == Qt.Key_Down: column = self.currentIndex().column() self.toggle_auto_filter(column) event.accept() else: super().keyPressEvent(event)
[docs] def setModel(self, model): """Disconnect sectionPressed signal, only connect it to show_filter_menu slot. Otherwise the column is selected when pressing on the header.""" super().setModel(model) self.horizontalHeader().sectionPressed.disconnect() self.horizontalHeader().sectionClicked.connect(self.toggle_auto_filter)
@Slot(int, name="show_filter_menu")
[docs] def toggle_auto_filter(self, logical_index): """Called when user clicks on a horizontal section header. Show/hide the auto filter widget.""" self.auto_filter_column = logical_index header_pos = self.mapToGlobal(self.horizontalHeader().pos()) pos_x = header_pos.x() + self.horizontalHeader().sectionViewportPosition(self.auto_filter_column) pos_y = header_pos.y() + self.horizontalHeader().height() width = self.horizontalHeader().sectionSize(logical_index) values = self.model().auto_filter_values(logical_index) self.auto_filter_menu.set_values(values) self.auto_filter_menu.popup(QPoint(pos_x, pos_y), width)
@Slot(name="update_auto_filter")
[docs] def update_auto_filter(self): """Called when the user selects Ok in the auto filter menu. Set 'filtered out values' in auto filter model.""" self.model().set_filtered_out_values(self.auto_filter_column, self.auto_filter_menu.unchecked_values)
@Slot(name="sort_model_ascending")
[docs] def sort_model_ascending(self): """Called when the user selects sort ascending in the auto filter widget.""" self.model().sort(self.auto_filter_column, Qt.AscendingOrder)
@Slot(name="sort_model_descending")
[docs] def sort_model_descending(self): """Called when the user selects sort descending in the auto filter widget.""" self.model().sort(self.auto_filter_column, Qt.DescendingOrder)
[docs]class FrozenTableView(QTableView): def __init__(self, parent=None): super(FrozenTableView, self).__init__(parent) self.model = TableModel() self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionMode(QAbstractItemView.SingleSelection) self.verticalHeader().setVisible(False) self.setSortingEnabled(True) self.setModel(self.model) self.is_updating = False
[docs] def clear(self): self.model.set_data([], [])
[docs] def get_selected_row(self): if self.model.columnCount() == 0: return () if self.model.rowCount() == 0: return tuple(None for _ in range(self.model.columnCount())) index = self.selectedIndexes() if not index: return tuple(None for _ in range(self.model.columnCount())) index = self.selectedIndexes()[0] return self.model.row(index)
[docs] def set_data(self, headers, values): self.selectionModel().blockSignals(True) # prevent selectionChanged signal when updating self.model.set_data(values, headers) self.selectRow(0) self.selectionModel().blockSignals(False)
[docs]class SimpleCopyPasteTableView(QTableView): """Custom QTableView class that copies and paste data in response to key press events. Attributes: parent (QWidget): The parent of this view """ def __init__(self, parent=None): """Initialize the class.""" super().__init__(parent) # self.editing = False self.clipboard = QApplication.clipboard() self.clipboard_text = self.clipboard.text() self.clipboard.dataChanged.connect(self.clipboard_data_changed) @Slot(name="clipboard_data_changed")
[docs] def clipboard_data_changed(self): self.clipboard_text = self.clipboard.text()
[docs] def keyPressEvent(self, event): """Copy and paste to and from clipboard in Excel-like format.""" if event.matches(QKeySequence.Copy): selection = self.selectionModel().selection() if not selection: super().keyPressEvent(event) return # Take only the first selection in case of multiple selection. first = selection.first() content = "" v_header = self.verticalHeader() h_header = self.horizontalHeader() for i in range(first.top(), first.bottom() + 1): if v_header.isSectionHidden(i): continue row = list() for j in range(first.left(), first.right() + 1): if h_header.isSectionHidden(j): continue row.append(str(self.model().index(i, j).data(Qt.DisplayRole))) content += "\t".join(row) content += "\n" self.clipboard.setText(content) elif event.matches(QKeySequence.Paste): if not self.clipboard_text: super().keyPressEvent(event) return top_left_index = self.currentIndex() if not top_left_index.isValid(): super().keyPressEvent(event) return data = [line.split('\t') for line in self.clipboard_text.split('\n')[0:-1]] self.selectionModel().select(top_left_index, QItemSelectionModel.Select) self.model().paste_data(top_left_index, data) else: super().keyPressEvent(event)
[docs]class IndexedParameterValueTableViewBase(CopyPasteTableView): """ Custom QTableView base class with copy and paste methods for indexed parameter values. """
[docs] def copy(self): """Copy current selection to clipboard in CSV format.""" selection_model = self.selectionModel() if not selection_model.hasSelection(): return False selected_indexes = sorted(selection_model.selectedIndexes(), key=lambda index: 2 * index.row() + index.column()) row_first = selected_indexes[0].row() row_last = selected_indexes[-1].row() row_count = row_last - row_first + 1 data_indexes = row_count * [None] data_values = row_count * [None] data_model = self.model() for selected_index in selected_indexes: data = data_model.data(selected_index) row = selected_index.row() if selected_index.column() == 0: data_indexes[row - row_first] = data else: data_values[row - row_first] = data with io.StringIO() as output: writer = csv.writer(output, delimiter='\t') if all(stamp is None for stamp in data_indexes): for value in data_values: writer.writerow([locale.str(value) if value is not None else ""]) elif all(value is None for value in data_values): for index in data_indexes: writer.writerow([index if index is not None else ""]) else: for index, value in zip(data_indexes, data_values): index = index if index is not None else "" value = locale.str(value) if value is not None else "" writer.writerow([index, value]) QApplication.clipboard().setText(output.getvalue()) return True
@staticmethod
[docs] def _read_pasted_text(text): """Reads CSV formatted table.""" raise NotImplementedError()
[docs] def paste(self): """Pastes data from clipboard to selection.""" raise NotImplementedError()
@staticmethod
[docs] def _range(indexes): """ Returns the top left and bottom right corners of selected model indexes. Args: indexes (list): a list of selected QModelIndex objects Returns: a tuple (top row, bottom row, left column, right column) """ rows = np.empty(len(indexes), dtype=int) columns = np.empty(len(indexes), dtype=int) for i, index in enumerate(indexes): rows[i] = index.row() columns[i] = index.column() return np.amin(rows), np.amax(rows), np.amin(columns), np.amax(columns)
[docs] def _select_pasted(self, indexes): """Selects the given model indexes.""" selection_model = self.selectionModel() selection_model.clear() for index in indexes: selection_model.select(index, QItemSelectionModel.Select)
[docs]class TimeSeriesFixedResolutionTableView(IndexedParameterValueTableViewBase): """A QTableView for fixed resolution time series table."""
[docs] def paste(self): """Pastes data from clipboard.""" selection_model = self.selectionModel() if not selection_model.hasSelection(): return False clipboard = QApplication.clipboard() mime_data = clipboard.mimeData() data_formats = mime_data.formats() if not 'text/plain' in data_formats: return False try: pasted_table = self._read_pasted_text(QApplication.clipboard().text()) except ValueError: return False selected_indexes = selection_model.selectedIndexes() if isinstance(pasted_table, tuple): # Always use the first column pasted_table = pasted_table[0] paste_length = len(pasted_table) first_row, last_row, _, _ = self._range(selected_indexes) selection_length = last_row - first_row + 1 model = self.model() model_row_count = model.rowCount() if selection_length == 1: # If a single row is selected, we paste everything. if model_row_count <= first_row + paste_length: model.insertRows(model_row_count, paste_length - (model_row_count - first_row)) elif paste_length > selection_length: # If multiple row are selected, we paste what fits the selection. paste_length = selection_length pasted_table = pasted_table[0:selection_length] indexes_to_set, values_to_set = self._paste_to_values_column(pasted_table, first_row, paste_length) model.batch_set_data(indexes_to_set, values_to_set) self._select_pasted(indexes_to_set)
@staticmethod
[docs] def _read_pasted_text(text): """ Parses the given CSV table. Parsing is locale aware. Args: text (str): a CSV table containing numbers Returns: A list of floats """ with io.StringIO(text) as input_stream: reader = csv.reader(input_stream, delimiter='\t') single_column = list() for row in reader: number = locale.atof(row[0]) single_column.append(number) return single_column
[docs] def _paste_to_values_column(self, values, first_row, paste_length): """ Pastes data to the Values column. Args: values (list): a list of float values to paste first_row (int): index of the first row where to paste paste_length (int): length of the paste selection (can be different from len(values)) Returns: A tuple (list(pasted indexes), list(pasted values)) """ values_to_set = list() indexes_to_set = list() create_model_index = self.model().index # Always paste to the Values column. for row in range(first_row, first_row + paste_length): values_to_set.append(values[row - first_row]) indexes_to_set.append(create_model_index(row, 1)) return indexes_to_set, values_to_set
[docs]class IndexedValueTableView(IndexedParameterValueTableViewBase): """A QTableView class with for variable resolution time series and time patterns."""
[docs] def paste(self): """Pastes data from clipboard.""" selection_model = self.selectionModel() if not selection_model.hasSelection(): return False clipboard = QApplication.clipboard() mime_data = clipboard.mimeData() data_formats = mime_data.formats() if not 'text/plain' in data_formats: return False try: pasted_table = self._read_pasted_text(QApplication.clipboard().text()) except ValueError: return False selected_indexes = selection_model.selectedIndexes() paste_single_column = isinstance(pasted_table, list) paste_length = len(pasted_table) if paste_single_column else len(pasted_table[0]) first_row, last_row, first_column, _ = self._range(selected_indexes) selection_length = last_row - first_row + 1 model = self.model() model_row_count = model.rowCount() if selection_length == 1: # If a single row is selected, we paste everything. if model_row_count <= first_row + paste_length: model.insertRows(model_row_count, paste_length - (model_row_count - first_row)) elif paste_length > selection_length: # If multiple row are selected, we paste what fits the selection. paste_length = selection_length if paste_single_column: pasted_table = pasted_table[0:selection_length] else: pasted_table = pasted_table[0][0:selection_length], pasted_table[1][0:selection_length] if paste_single_column: indexes_to_set, values_to_set = self._paste_single_column( pasted_table, first_row, first_column, paste_length ) else: indexes_to_set, values_to_set = self._paste_two_columns( pasted_table[0], pasted_table[1], first_row, paste_length ) model.batch_set_data(indexes_to_set, values_to_set) self._select_pasted(indexes_to_set)
[docs] def _paste_two_columns(self, data_indexes, data_values, first_row, paste_length): """ Pastes data indexes and values. Args: data_indexes (list): a list of data indexes (time stamps/durations) data_values (list): a list of data values first_row (int): first row index paste_length (int): selection length for pasting Returns: a tuple (modified model indexes, modified model values) """ values_to_set = list() indexes_to_set = list() create_model_index = self.model().index for row in range(first_row, first_row + paste_length): i = row - first_row values_to_set.append(data_indexes[i]) indexes_to_set.append(create_model_index(row, 0)) values_to_set.append(data_values[i]) indexes_to_set.append(create_model_index(row, 1)) return indexes_to_set, values_to_set
[docs] def _paste_single_column(self, values, first_row, first_column, paste_length): """ Pastes a single column of data Args: values (list): a list of data to paste (data indexes or values) first_row (int): first row index paste_length (int): selection length for pasting Returns: a tuple (modified model indexes, modified model values) """ values_to_set = list() indexes_to_set = list() create_model_index = self.model().index # Always paste numbers to the Values column. target_column = first_column if not isinstance(values[0], float) else 1 for row in range(first_row, first_row + paste_length): values_to_set.append(values[row - first_row]) indexes_to_set.append(create_model_index(row, target_column)) return indexes_to_set, values_to_set
@staticmethod
[docs] def _read_pasted_text(text): """ Parses a given CSV table Args: text (str): a CSV table Returns: a tuple (data indexes, data values) """ with io.StringIO(text) as input_stream: reader = csv.reader(input_stream, delimiter='\t') single_column = list() data_indexes = list() data_values = list() for row in reader: column_count = len(row) if column_count == 1: try: number = locale.atof(row[0]) single_column.append(number) except ValueError: single_column.append(row[0]) elif column_count > 1: data_indexes.append(row[0]) data_values.append(locale.atof(row[1])) if single_column: if data_indexes: # Don't know how to handle a mixture of single and multiple columns. raise ValueError() return single_column return data_indexes, data_values