Source code for widgets.custom_editors

######################################################################################################################
# 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 editors for model/view programming.


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

import json
import sys
from PySide2.QtCore import (
    Qt,
    Slot,
    Signal,
    QItemSelectionModel,
    QSortFilterProxyModel,
    QTimer,
    QEvent,
    QCoreApplication,
    QModelIndex,
    QPoint,
    QSize,
)
from PySide2.QtWidgets import (
    QComboBox,
    QDoubleSpinBox,
    QLineEdit,
    QTableView,
    QItemDelegate,
    QTabWidget,
    QWidget,
    QVBoxLayout,
    QTextEdit,
    QColorDialog,
    QDialog,
    QDialogButtonBox,
    QListView,
    QStyle,
    QLabel,
)
from PySide2.QtGui import QIntValidator, QStandardItemModel, QStandardItem, QColor
from treeview_models import LazyLoadingArrayModel
from widgets.custom_qtableview import CopyPasteTableView
from helpers import IconListManager, interpret_icon_id, make_icon_id


[docs]class CustomLineEditor(QLineEdit): """A custom QLineEdit to handle data from models. Attributes: parent (QWidget): the widget that wants to edit the data """
[docs] def set_data(self, data): if data is not None: self.setText(str(data)) if isinstance(data, int): self.setValidator(QIntValidator(self))
[docs] def data(self): return self.text()
[docs] def keyPressEvent(self, event): """Don't allow shift key to clear the contents.""" if event.key() != Qt.Key_Shift: super().keyPressEvent(event)
[docs]class CustomComboEditor(QComboBox): """A custom QComboBox to handle data from models. Attributes: parent (QWidget): the widget that wants to edit the data """
[docs] data_committed = Signal(name="data_committed")
[docs] def set_data(self, current_text, items): self.addItems(items) if current_text and current_text in items: self.setCurrentText(current_text) else: self.setCurrentIndex(-1) self.activated.connect(lambda: self.data_committed.emit()) # pylint: disable=unnecessary-lambda self.showPopup()
[docs] def data(self): return self.currentText()
[docs]class CustomLineEditDelegate(QItemDelegate): """A delegate for placing a CustomLineEditor on the first row of SearchBarEditor. Attributes: parent (SearchBarEditor): search bar editor """
[docs] text_edited = Signal("QString", name="text_edited")
def __init__(self, parent): """Init class.""" super().__init__(parent) self._parent = parent
[docs] def setModelData(self, editor, model, index): model.setData(index, editor.data())
[docs] def createEditor(self, parent, option, index): """Create editor and 'forward' `textEdited` signal. """ editor = CustomLineEditor(parent) editor.set_data(index.data()) editor.textEdited.connect(lambda s: self.text_edited.emit(s)) # pylint: disable=unnecessary-lambda return editor
[docs] def eventFilter(self, editor, event): """Handle all sort of special cases. """ if event.type() == QEvent.KeyPress and event.key() in (Qt.Key_Tab, Qt.Key_Backtab): # Bring focus to parent so tab editing works as expected self._parent.setFocus() return QCoreApplication.sendEvent(self._parent, event) if event.type() == QEvent.FocusOut: # Send event to parent so it gets closed when clicking on an empty area of the table return QCoreApplication.sendEvent(self._parent, event) if event.type() == QEvent.ShortcutOverride and event.key() == Qt.Key_Escape: # Close editor so we don't need to escape twice to close the parent SearchBarEditor self._parent.closeEditor(editor, QItemDelegate.NoHint) return True return super().eventFilter(editor, event)
[docs]class SearchBarEditor(QTableView): """A Google-like search bar, implemented as a QTableView with a CustomLineEditDelegate in the first row. Attributes: parent (QWidget): the parent for this widget elder_sibling (QWidget or NoneType): another widget which is used to find this widget's position. """
[docs] data_committed = Signal(name="data_committed")
def __init__(self, parent, elder_sibling=None, is_json=False): """Initialize class.""" super().__init__(parent) self._parent = parent self._elder_sibling = elder_sibling self._is_json = is_json self._base_size = None self._original_text = None self._orig_pos = None self.first_index = QModelIndex() self.model = QStandardItemModel(self) self.proxy_model = QSortFilterProxyModel(self) self.proxy_model.setSourceModel(self.model) self.proxy_model.filterAcceptsRow = self._proxy_model_filter_accepts_row self.setModel(self.proxy_model) self.verticalHeader().hide() self.horizontalHeader().hide() self.setShowGrid(False) self.setMouseTracking(True) self.setTabKeyNavigation(False) delegate = CustomLineEditDelegate(self) delegate.text_edited.connect(self._handle_delegate_text_edited) self.setItemDelegateForRow(0, delegate)
[docs] def set_data(self, current, all_data): """Populate model and initialize first index.""" if self._is_json: all_data = [json.loads(x) for x in all_data] item_list = [QStandardItem(current)] for name in all_data: qitem = QStandardItem(name) item_list.append(qitem) qitem.setFlags(~Qt.ItemIsEditable) self.model.invisibleRootItem().appendRows(item_list) self.first_index = self.proxy_model.mapFromSource(self.model.index(0, 0))
[docs] def set_base_size(self, size): self._base_size = size
[docs] def update_geometry(self): """Update geometry. Resize the widget to optimal size, then adjust its position. """ self.horizontalHeader().setDefaultSectionSize(self._base_size.width()) self.verticalHeader().setDefaultSectionSize(self._base_size.height()) self._orig_pos = self.pos() if self._elder_sibling: self._orig_pos += self._elder_sibling.mapTo(self._parent, self._elder_sibling.parent().pos()) self.refit()
[docs] def refit(self): """Resize to optimal size. """ self.move(self._orig_pos) table_height = self.verticalHeader().length() size = QSize(self._base_size.width(), table_height + 2).boundedTo(self._parent.size()) self.resize(size) # Adjust position if widget is outside parent's limits bottom_right = self.mapToGlobal(self.rect().bottomRight()) parent_bottom_right = self._parent.mapToGlobal(self._parent.rect().bottomRight()) x_offset = max(0, bottom_right.x() - parent_bottom_right.x()) y_offset = max(0, bottom_right.y() - parent_bottom_right.y()) self.move(self.pos() - QPoint(x_offset, y_offset))
[docs] def data(self): data = self.first_index.data(Qt.EditRole) if self._is_json: data = json.dumps(data) return data
@Slot("QString", name="_handle_delegate_text_edited")
[docs] def _handle_delegate_text_edited(self, text): """Filter model as the first row is being edited.""" self._original_text = text self.proxy_model.setFilterRegExp("^" + text) self.proxy_model.setData(self.first_index, text) self.refit()
[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 return QSortFilterProxyModel.filterAcceptsRow(self.proxy_model, source_row, source_parent)
[docs] def keyPressEvent(self, event): """Set data from current index into first index as the user navigates through the table using the up and down keys. """ super().keyPressEvent(event) event.accept() # Important to avoid unhandled behavior when trying to navigate outside view limits # Initialize original text. TODO: Is there a better place for this? if self._original_text is None: self.proxy_model.setData(self.first_index, event.text()) self._handle_delegate_text_edited(event.text()) # Set data from current index in model if event.key() in (Qt.Key_Up, Qt.Key_Down): current = self.currentIndex() if current.row() == 0: self.proxy_model.setData(self.first_index, self._original_text) else: self.proxy_model.setData(self.first_index, current.data())
[docs] def currentChanged(self, current, previous): super().currentChanged(current, previous) self.edit_first_index()
[docs] def edit_first_index(self): """Edit first index if valid and not already being edited. """ if not self.first_index.isValid(): return if self.isPersistentEditorOpen(self.first_index): return self.edit(self.first_index)
[docs] def mouseMoveEvent(self, event): """Make hovered index the current index.""" if not self.currentIndex().isValid(): return index = self.indexAt(event.pos()) if index.row() == 0: return self.setCurrentIndex(index)
[docs] def mousePressEvent(self, event): """Commit data.""" index = self.indexAt(event.pos()) if index.row() == 0: return self.proxy_model.setData(self.first_index, index.data(Qt.EditRole)) self.data_committed.emit()
[docs]class SearchBarDelegate(QItemDelegate): """A custom delegate to place a SearchBarEditor on each cell of a MultiSearchBarEditor. Attributes: parent (MultiSearchBarEditor): multi search bar editor """
[docs] data_committed = Signal("QModelIndex", "QVariant", name="data_committed")
def __init__(self, parent): super().__init__(parent) self._parent = parent
[docs] def setModelData(self, editor, model, index): model.setData(index, editor.data())
[docs] def createEditor(self, parent, option, index): editor = SearchBarEditor(parent) editor.set_data(index.data(), self._parent.alls[index.column()]) model = index.model() editor.data_committed.connect(lambda e=editor, i=index, m=model: self.close_editor(e, i, m)) return editor
[docs] def updateEditorGeometry(self, editor, option, index): super().updateEditorGeometry(editor, option, index) size = option.rect.size() editor.set_base_size(size) editor.update_geometry()
[docs] def close_editor(self, editor, index, model): self.closeEditor.emit(editor) self.setModelData(editor, model, index)
[docs] def eventFilter(self, editor, event): if event.type() == QEvent.FocusOut: super().eventFilter(editor, event) return QCoreApplication.sendEvent(self._parent, event) return super().eventFilter(editor, event)
[docs]class MultiSearchBarEditor(QTableView): """A table view made of several Google-like search bars.""" def __init__(self, parent, elder_sibling=None): """Initialize class.""" super().__init__(parent) self._parent = parent self._elder_sibling = elder_sibling self.alls = None self._max_item_count = None self._base_size = None self.model = QStandardItemModel(self) self.setModel(self.model) delegate = SearchBarDelegate(self) self.setItemDelegate(delegate) self.verticalHeader().hide() self.horizontalHeader().setStretchLastSection(True)
[docs] def set_data(self, header, currents, alls): self.model.setHorizontalHeaderLabels(header) self.alls = alls self._max_item_count = max(len(x) for x in alls) item_list = [] for k in range(len(header)): try: current = currents[k] except IndexError: current = None qitem = QStandardItem(current) item_list.append(qitem) self.model.invisibleRootItem().appendRow(item_list) QTimer.singleShot(0, self.start_editing)
[docs] def data(self): return ",".join(self.model.index(0, j).data() for j in range(self.model.columnCount()))
[docs] def set_base_size(self, size): self._base_size = size
[docs] def update_geometry(self): """Update geometry. """ self.horizontalHeader().setDefaultSectionSize(self._base_size.width() / self.model.columnCount()) self.horizontalHeader().setMaximumHeight(self._base_size.height()) self.verticalHeader().setDefaultSectionSize(self._base_size.height()) size = QSize(self._base_size.width(), self._base_size.height() * (self._max_item_count + 2) + 2).boundedTo( self._parent.size() ) self.resize(size) if self._elder_sibling: self.move(self.pos() + self._elder_sibling.mapTo(self._parent, self._elder_sibling.parent().pos())) # Adjust position if widget is outside parent's limits bottom_right = self.mapToGlobal(self.rect().bottomRight()) parent_bottom_right = self._parent.mapToGlobal(self._parent.rect().bottomRight()) x_offset = max(0, bottom_right.x() - parent_bottom_right.x()) y_offset = max(0, bottom_right.y() - parent_bottom_right.y()) self.move(self.pos() - QPoint(x_offset, y_offset))
[docs] def start_editing(self): """Start editing first item. """ index = self.model.index(0, 0) self.setCurrentIndex(index) self.edit(index)
[docs]class CheckListEditor(QTableView): """A check list editor.""" def __init__(self, parent, elder_sibling=None): """Initialize class.""" super().__init__(parent) self._parent = parent self._elder_sibling = elder_sibling self._base_size = None self.model = QStandardItemModel(self) self.setModel(self.model) self.verticalHeader().hide() self.horizontalHeader().hide() self.setShowGrid(False) self.setMouseTracking(True)
[docs] def keyPressEvent(self, event): """Toggle checked state.""" super().keyPressEvent(event) if event.key() == Qt.Key_Space: index = self.currentIndex() self.toggle_checked_state(index)
[docs] def toggle_checked_state(self, index): item = self.model.itemFromIndex(index) if item.checkState() == Qt.Checked: item.setCheckState(Qt.Unchecked) else: item.setCheckState(Qt.Checked)
[docs] def mouseMoveEvent(self, event): """Highlight current row.""" index = self.indexAt(event.pos()) self.setCurrentIndex(index)
[docs] def mousePressEvent(self, event): """Toggle checked state.""" index = self.indexAt(event.pos()) self.toggle_checked_state(index)
[docs] def set_data(self, item_names, current_item_names): """Set data and update geometry.""" for name in item_names: qitem = QStandardItem(name) if name in current_item_names: qitem.setCheckState(Qt.Checked) else: qitem.setCheckState(Qt.Unchecked) qitem.setFlags(~Qt.ItemIsEditable & ~Qt.ItemIsUserCheckable) qitem.setData(qApp.palette().window(), Qt.BackgroundRole) # pylint: disable=undefined-variable self.model.appendRow(qitem) self.selectionModel().select(self.model.index(0, 0), QItemSelectionModel.Select)
[docs] def data(self): data = [] for q in self.model.findItems('*', Qt.MatchWildcard): if q.checkState() == Qt.Checked: data.append(q.text()) return ",".join(data)
[docs] def set_base_size(self, size): self._base_size = size
[docs] def update_geometry(self): """Update geometry. """ self.horizontalHeader().setDefaultSectionSize(self._base_size.width()) self.verticalHeader().setDefaultSectionSize(self._base_size.height()) total_height = self.verticalHeader().length() + 2 size = QSize(self._base_size.width(), total_height).boundedTo(self._parent.size()) self.resize(size) if self._elder_sibling: self.move(self.pos() + self._elder_sibling.mapTo(self._parent, self._elder_sibling.parent().pos())) # Adjust position if widget is outside parent's limits bottom_right = self.mapToGlobal(self.rect().bottomRight()) parent_bottom_right = self._parent.mapToGlobal(self._parent.rect().bottomRight()) x_offset = max(0, bottom_right.x() - parent_bottom_right.x()) y_offset = max(0, bottom_right.y() - parent_bottom_right.y()) self.move(self.pos() - QPoint(x_offset, y_offset))
[docs]class JSONEditor(QTabWidget): """A double JSON editor, featuring: - A QTextEdit for editing arbitrary json. - A QTableView for editing json array. """
[docs] data_committed = Signal(name="data_committed")
def __init__(self, parent, elder_sibling, popup=False): """Initialize class.""" super().__init__(parent) self._parent = parent self._elder_sibling = elder_sibling self._popup = popup self.setTabPosition(QTabWidget.South) self.tab_raw = QWidget() vertical_layout = QVBoxLayout(self.tab_raw) vertical_layout.setSpacing(0) vertical_layout.setContentsMargins(0, 0, 0, 0) self.text_edit = QTextEdit(self.tab_raw) self.text_edit.setTabChangesFocus(True) vertical_layout.addWidget(self.text_edit) self.addTab(self.tab_raw, "Raw") self.tab_table = QWidget() vertical_layout = QVBoxLayout(self.tab_table) vertical_layout.setSpacing(0) vertical_layout.setContentsMargins(0, 0, 0, 0) self.table_view = CopyPasteTableView(self.tab_table) self.table_view.horizontalHeader().hide() self.table_view.setTabKeyNavigation(False) vertical_layout.addWidget(self.table_view) self.addTab(self.tab_table, "Table") self.setCurrentIndex(0) self._base_size = None self.model = LazyLoadingArrayModel(self) self.table_view.setModel(self.model) self.text_edit.installEventFilter(self) self.table_view.installEventFilter(self) self.table_view.keyPressEvent = self._view_key_press_event if popup: self.text_edit.setReadOnly(True) self.table_view.setEditTriggers(QTableView.NoEditTriggers) self.setFocusPolicy(Qt.NoFocus)
[docs] def _view_key_press_event(self, event): """Accept key events on the view to avoid weird behaviour, when trying to navigate outside of its limits. """ QTableView.keyPressEvent(self.table_view, event) event.accept()
[docs] def eventFilter(self, widget, event): """Intercept events to text_edit and table_view to enable consistent behavior. """ if event.type() == QEvent.KeyPress: if event.key() == Qt.Key_Tab: if widget == self.text_edit: self.setCurrentIndex(1) return True if widget == self.table_view: return QCoreApplication.sendEvent(self, event) if event.key() == Qt.Key_Backtab: if widget == self.table_view: self.setCurrentIndex(0) return True if widget == self.text_edit: return QCoreApplication.sendEvent(self, event) if event.key() == Qt.Key_Escape: self.setFocus() return QCoreApplication.sendEvent(self, event) if event.key() in (Qt.Key_Enter, Qt.Key_Return): if widget == self.table_view: if self.table_view.isPersistentEditorOpen(self.table_view.currentIndex()): return True self.setFocus() return QCoreApplication.sendEvent(self, event) return False if event.type() == QEvent.FocusOut: QTimer.singleShot(0, self.check_focus) return False
[docs] def check_focus(self): """Called when either the text edit or the table view lose focus. Check if the focus is still on this widget (which would mean it was a tab change) otherwise emit signal so this is closed. """ if qApp.focusWidget() != self.focusWidget(): # pylint: disable=undefined-variable self.data_committed.emit()
@Slot("int", name="_handle_current_changed")
[docs] def _handle_current_changed(self, index): """Update json data on text edit or table view, and set focus. """ if index == 0: data = self.model.all_data() if data is not None: formatted_data = json.dumps(data, indent=4) self.text_edit.setText(formatted_data) self.text_edit.setFocus() elif index == 1: text = self.text_edit.toPlainText() try: data = json.loads(text) except json.JSONDecodeError: data = None self.model.reset_model(data) self.table_view.setFocus() self.table_view.setCurrentIndex(self.model.index(0, 0)) self.table_view.selectionModel().clearSelection()
[docs] def set_data(self, data, current_index): """Set data on text edit or table view (model) depending on current index. """ self.setCurrentIndex(current_index) self.currentChanged.connect(self._handle_current_changed) try: loaded_data = json.loads(data) except (TypeError, json.JSONDecodeError): # NOTE: TypeError happens when data is None loaded_data = None if current_index == 0: if loaded_data is not None: formatted_data = json.dumps(loaded_data, indent=4) self.text_edit.setText(formatted_data) elif current_index == 1: self.model.reset_model(loaded_data) QTimer.singleShot(0, self.start_editing)
[docs] def start_editing(self): """Start editing. """ current_index = self.currentIndex() if current_index == 0: self.text_edit.setFocus() elif current_index == 1: self.table_view.setFocus()
[docs] def set_base_size(self, size): self._base_size = size
[docs] def update_geometry(self): """Update geometry. """ self.table_view.horizontalHeader().setDefaultSectionSize(self._base_size.width()) self.table_view.verticalHeader().setDefaultSectionSize(self._base_size.height()) if self._popup: base_width = 0.6 * self._base_size.width() offset = QPoint(0.4 * self._base_size.width(), 0) min_size = QSize(0, 0) else: base_width = self._base_size.width() offset = QPoint(0, 0) min_size = QSize(200, 0) size = QSize(base_width, self._base_size.height() * 16) # TODO: Try and get rid of the 16 here size = size.boundedTo(self._parent.size()) size = size.expandedTo(min_size) self.resize(size) self.move(offset + self.pos() + self._elder_sibling.mapTo(self._parent, self._elder_sibling.parent().pos())) # Adjust position if widget is outside parent's limits bottom_right = self.mapToGlobal(self.rect().bottomRight()) parent_bottom_right = self._parent.mapToGlobal(self._parent.rect().bottomRight()) x_offset = max(0, bottom_right.x() - parent_bottom_right.x()) y_offset = max(0, bottom_right.y() - parent_bottom_right.y()) self.move(self.pos() - QPoint(x_offset, y_offset))
[docs] def data(self): index = self.currentIndex() if index == 0: text = self.text_edit.toPlainText() if not text: # empty string is not valid JSON text = 'null' return text if index == 1: return json.dumps(self.model.all_data())
[docs]class IconPainterDelegate(QItemDelegate): """A delegate to highlight decorations in a QListWidget."""
[docs] def paint(self, painter, option, index): """Highlight selected items.""" if option.state & QStyle.State_Selected: painter.fillRect(option.rect, qApp.palette().highlight()) # pylint: disable=undefined-variable super().paint(painter, option, index)
[docs]class IconColorEditor(QDialog): """An editor to let the user select an icon and a color for an object class. """ def __init__(self, parent): """Init class.""" super().__init__(parent) # , Qt.Popup) icon_size = QSize(32, 32) self.icon_mngr = IconListManager(icon_size) self.setWindowTitle("Select icon and color") self.icon_widget = QWidget(self) self.icon_list = QListView(self.icon_widget) self.icon_list.setViewMode(QListView.IconMode) self.icon_list.setIconSize(icon_size) self.icon_list.setResizeMode(QListView.Adjust) self.icon_list.setItemDelegate(IconPainterDelegate(self)) self.icon_list.setMovement(QListView.Static) self.icon_list.setMinimumHeight(400) icon_widget_layout = QVBoxLayout(self.icon_widget) icon_widget_layout.addWidget(QLabel("Font Awesome icons")) self.line_edit = QLineEdit() self.line_edit.setPlaceholderText("Search icons for...") icon_widget_layout.addWidget(self.line_edit) icon_widget_layout.addWidget(self.icon_list) self.color_dialog = QColorDialog(self) self.color_dialog.setWindowFlags(Qt.Widget) self.color_dialog.setOption(QColorDialog.NoButtons, True) self.color_dialog.setOption(QColorDialog.DontUseNativeDialog, True) self.button_box = QDialogButtonBox(self) self.button_box.setStandardButtons(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) layout = QVBoxLayout(self) layout.addWidget(self.icon_widget) layout.addWidget(self.color_dialog) layout.addWidget(self.button_box) self.proxy_model = QSortFilterProxyModel(self) self.proxy_model.setSourceModel(self.icon_mngr.model) self.proxy_model.filterAcceptsRow = self._proxy_model_filter_accepts_row self.icon_list.setModel(self.proxy_model) self.setAttribute(Qt.WA_DeleteOnClose) self.connect_signals()
[docs] def _proxy_model_filter_accepts_row(self, source_row, source_parent): """Overridden method to filter icons according to search terms. """ text = self.line_edit.text() if not text: return QSortFilterProxyModel.filterAcceptsRow(self.proxy_model, source_row, source_parent) searchterms = self.icon_mngr.model.index(source_row, 0, source_parent).data(Qt.UserRole + 1) return any([text in term for term in searchterms])
[docs] def connect_signals(self): """Connect signals to slots.""" self.line_edit.textEdited.connect(self.proxy_model.invalidateFilter) self.button_box.accepted.connect(self.accept) self.button_box.rejected.connect(self.reject)
[docs] def set_data(self, data): icon_code, color_code = interpret_icon_id(data) self.icon_mngr.init_model() for i in range(self.proxy_model.rowCount()): index = self.proxy_model.index(i, 0) if index.data(Qt.UserRole) == icon_code: self.icon_list.setCurrentIndex(index) break self.color_dialog.setCurrentColor(QColor(color_code))
[docs] def data(self): icon_code = self.icon_list.currentIndex().data(Qt.UserRole) color_code = self.color_dialog.currentColor().rgb() return make_icon_id(icon_code, color_code)
[docs]class NumberParameterInlineEditor(QDoubleSpinBox): """ An editor widget for numeric (datatype double) parameter values. """ def __init__(self, parent): super().__init__(parent) self.setRange(-sys.float_info.max, sys.float_info.max) self.setDecimals(sys.float_info.mant_dig)
[docs] def set_data(self, data): if data is not None: self.setValue(float(data))
[docs] def data(self): return str(self.value())