######################################################################################################################
# Copyright (C) 2017-2021 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
"""
from PySide2.QtCore import Qt, Slot, Signal, QSortFilterProxyModel, QEvent, QCoreApplication, QModelIndex, QPoint, QSize
from PySide2.QtWidgets import (
QComboBox,
QLineEdit,
QTableView,
QStyledItemDelegate,
QWidget,
QVBoxLayout,
QHBoxLayout,
QColorDialog,
QDialog,
QDialogButtonBox,
QListView,
QStyle,
QLabel,
)
from PySide2.QtGui import QStandardItemModel, QStandardItem, QColor, QIcon, QPixmap, QPainter
from ..helpers import IconListManager, interpret_icon_id, make_icon_id, try_number_from_string
[docs]class CustomLineEditor(QLineEdit):
"""A custom QLineEdit to handle data from models.
"""
[docs] def set_data(self, data):
"""Sets editor's text.
Args:
data (Any): anything convertible to string
"""
if data is not None:
self.setText(str(data))
[docs] def data(self):
"""Returns editor's text.
Returns:
str: editor's text
"""
return self.text()
[docs] def keyPressEvent(self, event):
"""Prevents shift key press to clear the contents."""
if event.key() != Qt.Key_Shift:
super().keyPressEvent(event)
[docs]class ParameterValueLineEditor(CustomLineEditor):
[docs] def set_data(self, data):
if data is not None and not isinstance(data, str):
self.setAlignment(Qt.AlignRight)
super().set_data(data)
[docs] def data(self):
return try_number_from_string(super().data())
[docs]class _CustomLineEditDelegate(QStyledItemDelegate):
"""A delegate for placing a CustomLineEditor on the first row of SearchBarEditor.
"""
[docs] text_edited = Signal("QString")
[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, QStyledItemDelegate.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.
"""
[docs] data_committed = Signal()
def __init__(self, parent, tutor=None):
"""Initializes instance.
Args:
parent (QWidget): parent widget
tutor (QWidget, optional): another widget used for positioning.
"""
super().__init__(parent)
self._tutor = tutor
self._base_size = QSize()
self._base_offset = QPoint()
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, items):
"""Populates model.
Args:
current (str): item that is currently selected from given items
items (Sequence(str)): items to show in the list
"""
item_list = [QStandardItem(current)]
for item in items:
qitem = QStandardItem(item)
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 set_base_offset(self, offset):
self._base_offset = offset
[docs] def update_geometry(self):
"""Updates geometry.
"""
self.horizontalHeader().setDefaultSectionSize(self._base_size.width())
self.verticalHeader().setDefaultSectionSize(self._base_size.height())
self._orig_pos = self.pos() + self._base_offset
if self._tutor:
self._orig_pos += self._tutor.mapTo(self.parent(), self._tutor.rect().topLeft())
self.refit()
[docs] def refit(self):
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):
return self.first_index.data(Qt.EditRole)
@Slot(str)
[docs] def _handle_delegate_text_edited(self, text):
"""Filters 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):
"""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):
"""Sets 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):
"""Edits 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):
"""Sets the current index to the one hovered by the mouse."""
if not self.currentIndex().isValid():
return
index = self.indexAt(event.pos())
if index.row() == 0:
return
self.setCurrentIndex(index)
[docs] def mousePressEvent(self, event):
"""Commits 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 CheckListEditor(QTableView):
"""A check list editor."""
def __init__(self, parent, tutor=None, ranked=False):
"""Initialize class."""
super().__init__(parent)
self._tutor = tutor
self._ranked = ranked
self._base_size = None
self.model = QStandardItemModel(self)
self.setModel(self.model)
self.verticalHeader().hide()
self.horizontalHeader().hide()
self.setShowGrid(False)
self.setMouseTracking(True)
self._icons = []
self._selected = []
self._items = {}
self._blank_icon = self._make_icon()
[docs] def _make_icon(self, i=None):
if not self._ranked:
return None
pixmap = QPixmap(16, 16)
pixmap.fill(Qt.white)
if i is not None:
painter = QPainter(pixmap)
painter.drawText(0, 0, 16, 16, Qt.AlignCenter, str(i))
painter.end()
return QIcon(pixmap)
[docs] def keyPressEvent(self, event):
"""Toggles checked state if the user presses space."""
super().keyPressEvent(event)
if event.key() == Qt.Key_Space:
index = self.currentIndex()
self.toggle_selected(index)
[docs] def toggle_selected(self, index):
"""Adds or removes given index from selected items.
Args:
index (QModelIndex): index to toggle
"""
item = self.model.itemFromIndex(index).text()
qitem = self._items[item]
if item not in self._selected:
rank = len(self._selected)
self._select_item(qitem, rank)
self._selected.append(item)
else:
self._selected.remove(item)
self._deselect_item(qitem, update_ranks=True)
[docs] def _select_item(self, qitem, rank):
if self._ranked:
qitem.setData(self._icons[rank], Qt.DecorationRole)
else:
qitem.setCheckState(Qt.Checked)
[docs] def _deselect_item(self, qitem, update_ranks=False):
if self._ranked:
qitem.setData(self._blank_icon, Qt.DecorationRole)
if update_ranks:
for rank, item in enumerate(self._selected):
qitem = self._items[item]
self._select_item(qitem, rank)
else:
qitem.setCheckState(Qt.Unchecked)
[docs] def mouseMoveEvent(self, event):
"""Sets the current index to the one under mouse."""
index = self.indexAt(event.pos())
self.setCurrentIndex(index)
[docs] def mousePressEvent(self, event):
"""Toggles checked state of pressed index."""
index = self.indexAt(event.pos())
self.toggle_selected(index)
[docs] def set_data(self, items, checked_items):
"""Sets data and updates geometry.
Args:
items (Sequence(str)): All items.
checked_items (Sequence(str)): Initially checked items.
"""
self._icons = [self._make_icon(i + 1) for i in range(len(items))]
for item in items:
qitem = QStandardItem(item)
qitem.setFlags(~Qt.ItemIsEditable)
qitem.setData(qApp.palette().window(), Qt.BackgroundRole) # pylint: disable=undefined-variable
self._deselect_item(qitem)
self._items[item] = qitem
self.model.appendRow(qitem)
self._selected = [item for item in checked_items if item in items]
for rank, item in enumerate(self._selected):
qitem = self._items[item]
self._select_item(qitem, rank)
[docs] def data(self):
"""Returns a comma separated list of checked items.
Returns
str
"""
return ",".join(self._selected)
[docs] def set_base_size(self, size):
self._base_size = size
[docs] def update_geometry(self):
"""Updates 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._tutor:
self.move(self.pos() + self._tutor.mapTo(self.parent(), self._tutor.rect().topLeft()))
# 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 _IconPainterDelegate(QStyledItemDelegate):
"""A delegate to highlight decorations in a QListWidget."""
[docs] def paint(self, painter, option, index):
"""Paints selected items using the highlight brush."""
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)
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)
top_widget = QWidget(self)
top_layout = QHBoxLayout(top_widget)
top_layout.addWidget(self.icon_widget)
top_layout.addWidget(self.color_dialog)
layout = QVBoxLayout(self)
layout.addWidget(top_widget)
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):
"""Filters 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):
"""Connects 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)