Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FIX] Edit Domain: Multiple item rename/merge #4949

Merged
merged 4 commits into from
Sep 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 135 additions & 23 deletions Orange/widgets/data/oweditdomain.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from functools import singledispatch, partial
from typing import (
Tuple, List, Any, Optional, Union, Dict, Sequence, Iterable, NamedTuple,
FrozenSet, Type, Callable, TypeVar, Mapping, Hashable
FrozenSet, Type, Callable, TypeVar, Mapping, Hashable, cast
)

import numpy as np
Expand All @@ -23,10 +23,13 @@
QLineEdit, QAction, QActionGroup, QGroupBox,
QStyledItemDelegate, QStyleOptionViewItem, QStyle, QSizePolicy,
QDialogButtonBox, QPushButton, QCheckBox, QComboBox, QStackedLayout,
QDialog, QRadioButton, QGridLayout, QLabel, QSpinBox, QDoubleSpinBox)
QDialog, QRadioButton, QGridLayout, QLabel, QSpinBox, QDoubleSpinBox,
QAbstractItemView, QMenu
)
from AnyQt.QtGui import QStandardItemModel, QStandardItem, QKeySequence, QIcon
from AnyQt.QtCore import (
Qt, QSize, QModelIndex, QAbstractItemModel, QPersistentModelIndex
Qt, QSize, QModelIndex, QAbstractItemModel, QPersistentModelIndex, QRect,
QPoint,
)
from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot

Expand All @@ -36,6 +39,7 @@
from Orange.widgets import widget, gui, settings
from Orange.widgets.utils import itemmodels
from Orange.widgets.utils.buttons import FixedSizeButton
from Orange.widgets.utils.itemmodels import signal_blocking
from Orange.widgets.utils.widgetpreview import WidgetPreview
from Orange.widgets.utils.state_summary import format_summary_details
from Orange.widgets.widget import Input, Output
Expand Down Expand Up @@ -466,8 +470,6 @@ def get_dict(self):
return rval




class VariableEditor(QWidget):
"""
An editor widget for a variable.
Expand Down Expand Up @@ -997,6 +999,14 @@ def keyRoles(self): # type: () -> FrozenSet[int]
return frozenset({Qt.EditRole, EditStateRole})


def mapRectTo(widget: QWidget, parent: QWidget, rect: QRect) -> QRect: # pylint: disable=redefined-outer-name
return QRect(widget.mapTo(parent, rect.topLeft()), rect.size())


def mapRectToGlobal(widget: QWidget, rect: QRect) -> QRect: # pylint: disable=redefined-outer-name
return QRect(widget.mapToGlobal(rect.topLeft()), rect.size())


class CategoriesEditDelegate(QStyledItemDelegate):
"""
Display delegate for editing categories.
Expand Down Expand Up @@ -1028,6 +1038,64 @@ def initStyleOption(self, option, index):
text = text + " " + suffix
option.text = text

class CatEditComboBox(QComboBox):
prows: List[QPersistentModelIndex]

def createEditor(
self, parent: QWidget, option: 'QStyleOptionViewItem',
index: QModelIndex
) -> QWidget:
view = option.widget
assert isinstance(view, QAbstractItemView)
selmodel = view.selectionModel()
rows = selmodel.selectedRows(0)
if len(rows) < 2:
return super().createEditor(parent, option, index)
# edit multiple selection
cb = CategoriesEditDelegate.CatEditComboBox(
editable=True, insertPolicy=QComboBox.InsertAtBottom)
cb.setParent(view, Qt.Popup)
cb.addItems(
list(unique(str(row.data(Qt.EditRole)) for row in rows)))
prows = [QPersistentModelIndex(row) for row in rows]
cb.prows = prows
return cb

def updateEditorGeometry(
self, editor: QWidget, option: 'QStyleOptionViewItem',
index: QModelIndex
) -> None:
if isinstance(editor, CategoriesEditDelegate.CatEditComboBox):
view = cast(QAbstractItemView, option.widget)
view.scrollTo(index)
vport = view.viewport()
vrect = view.visualRect(index)
vrect = mapRectTo(vport, view, vrect)
vrect = vrect.intersected(vport.geometry())
vrect = mapRectToGlobal(vport, vrect)
size = editor.sizeHint().expandedTo(vrect.size())
editor.resize(size)
editor.move(vrect.topLeft())
else:
super().updateEditorGeometry(editor, option, index)

def setModelData(
self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex
) -> None:
if isinstance(editor, CategoriesEditDelegate.CatEditComboBox):
text = editor.currentText()
with signal_blocking(model):
for prow in editor.prows:
if prow.isValid():
model.setData(QModelIndex(prow), text, Qt.EditRole)
# this could be better
model.dataChanged.emit(
model.index(0, 0), model.index(model.rowCount() - 1, 0),
(Qt.EditRole,)
)
else:
super().setModelData(editor, model, index)


class DiscreteVariableEditor(VariableEditor):
"""An editor widget for editing a discrete variable.
Expand Down Expand Up @@ -1069,7 +1137,8 @@ def __init__(self, *args, **kwargs):
self, objectName="action-group-categories", enabled=False
)
self.move_value_up = QAction(
"\N{UPWARDS ARROW}", group,
"Move up", group,
iconText="\N{UPWARDS ARROW}",
toolTip="Move the selected item up.",
shortcut=QKeySequence(Qt.ControlModifier | Qt.AltModifier |
Qt.Key_BracketLeft),
Expand All @@ -1078,7 +1147,8 @@ def __init__(self, *args, **kwargs):
self.move_value_up.triggered.connect(self.move_up)

self.move_value_down = QAction(
"\N{DOWNWARDS ARROW}", group,
"Move down", group,
iconText="\N{DOWNWARDS ARROW}",
toolTip="Move the selected item down.",
shortcut=QKeySequence(Qt.ControlModifier | Qt.AltModifier |
Qt.Key_BracketRight),
Expand All @@ -1087,29 +1157,41 @@ def __init__(self, *args, **kwargs):
self.move_value_down.triggered.connect(self.move_down)

self.add_new_item = QAction(
"+", group,
"Add", group,
iconText="+",
objectName="action-add-item",
toolTip="Append a new item.",
shortcut=QKeySequence(QKeySequence.New),
shortcutContext=Qt.WidgetShortcut,
)
self.remove_item = QAction(
"\N{MINUS SIGN}", group,
"Remove item", group,
iconText="\N{MINUS SIGN}",
objectName="action-remove-item",
toolTip="Delete the selected item.",
shortcut=QKeySequence(QKeySequence.Delete),
shortcutContext=Qt.WidgetShortcut,
)
self.merge_items = QAction(
"M", group,
objectName="action-merge-item",
toolTip="Merge selected items.",
self.rename_selected_items = QAction(
"Rename selected items", group,
iconText="=",
objectName="action-rename-selected-items",
toolTip="Rename selected items.",
shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_Equal),
shortcutContext=Qt.WidgetShortcut,
)
self.merge_items = QAction(
"Merge", group,
iconText="M",
objectName="action-activate-merge-dialog",
toolTip="Merge infrequent items.",
shortcut=QKeySequence(Qt.ControlModifier | Qt.MetaModifier | Qt.Key_Equal),
shortcutContext=Qt.WidgetShortcut
)

self.add_new_item.triggered.connect(self._add_category)
self.remove_item.triggered.connect(self._remove_category)
self.rename_selected_items.triggered.connect(self._rename_selected_categories)
self.merge_items.triggered.connect(self._merge_categories)

button1 = FixedSizeButton(
Expand All @@ -1129,18 +1211,37 @@ def __init__(self, *args, **kwargs):
accessibleName="Remove"
)
button5 = FixedSizeButton(
self, defaultAction=self.rename_selected_items,
accessibleName="Merge selected items"
)
button6 = FixedSizeButton(
self, defaultAction=self.merge_items,
accessibleName="Merge",
accessibleName="Merge infrequent",
)
self.values_edit.addActions([self.move_value_up, self.move_value_down,
self.add_new_item, self.remove_item])

self.values_edit.addActions([
self.move_value_up, self.move_value_down,
self.add_new_item, self.remove_item, self.rename_selected_items
])
self.values_edit.setContextMenuPolicy(Qt.CustomContextMenu)

def context_menu(pos: QPoint):
viewport = self.values_edit.viewport()
menu = QMenu(self.values_edit)
menu.setAttribute(Qt.WA_DeleteOnClose)
menu.addActions([self.rename_selected_items, self.remove_item])
menu.popup(viewport.mapToGlobal(pos))
self.values_edit.customContextMenuRequested.connect(context_menu)

hlayout.addWidget(button1)
hlayout.addWidget(button2)
hlayout.addSpacing(3)
hlayout.addWidget(button3)
hlayout.addWidget(button4)
hlayout.addSpacing(3)
hlayout.addWidget(button5)
hlayout.addWidget(button6)

hlayout.addStretch(10)
vlayout.addLayout(hlayout)

Expand All @@ -1151,6 +1252,8 @@ def __init__(self, *args, **kwargs):
QWidget.setTabOrder(button1, button2)
QWidget.setTabOrder(button2, button3)
QWidget.setTabOrder(button3, button4)
QWidget.setTabOrder(button4, button5)
QWidget.setTabOrder(button5, button6)

def set_data(self, var, transform=()):
raise NotImplementedError
Expand Down Expand Up @@ -1293,13 +1396,6 @@ def on_values_changed(self):
@Slot()
def on_value_selection_changed(self):
rows = self.values_edit.selectionModel().selectedRows()
# enable merge if at least 2 selected items and the selection must not
# contain any added/dropped items.
enable_merge = \
len(rows) > 1 and \
not any(index.data(EditStateRole) != ItemEditState.NoState
for index in rows)

if len(rows) == 1:
i = rows[0].row()
self.move_value_up.setEnabled(i != 0)
Expand Down Expand Up @@ -1422,6 +1518,22 @@ def complete_merge(text, merge_attributes):
dlg.get_merged_value_name(), dlg.get_merge_attributes()
)

def _rename_selected_categories(self):
"""
Rename selected categories and merging them.

Popup an editable combo box for selection/edit of a new value.
"""
view = self.values_edit
selmodel = view.selectionModel()
index = view.currentIndex()
if not selmodel.isSelected(index):
indices = selmodel.selectedRows(0)
if indices:
index = indices[0]
# delegate to the CategoriesEditDelegate
view.edit(index)


class ContinuousVariableEditor(VariableEditor):
# TODO: enable editing of display format...
Expand Down
47 changes: 46 additions & 1 deletion Orange/widgets/data/tests/test_oweditdomain.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from AnyQt.QtCore import QItemSelectionModel, Qt, QItemSelection
from AnyQt.QtWidgets import QAction, QComboBox, QLineEdit, \
QStyleOptionViewItem, QDialog
QStyleOptionViewItem, QDialog, QMenu
from AnyQt.QtTest import QTest, QSignalSpy

from Orange.widgets.utils import colorpalettes
Expand All @@ -38,6 +38,7 @@
GroupItemsDialog)
from Orange.widgets.data.owcolor import OWColor, ColorRole
from Orange.widgets.tests.base import WidgetTest, GuiTest
from Orange.widgets.tests.utils import contextMenu
from Orange.tests import test_filename, assert_array_nanequal
from Orange.widgets.utils.state_summary import format_summary_details

Expand Down Expand Up @@ -517,6 +518,50 @@ def test_discrete_editor_merge_action(self):
self.assertEqual(model.index(0, 0).data(Qt.EditRole), "other")
self.assertEqual(model.index(1, 0).data(Qt.EditRole), "other")

def test_discrete_editor_rename_selected_items_action(self):
w = DiscreteVariableEditor()
v = Categorical("C", ("a", "b", "c"),
(("A", "1"), ("B", "b")), False)
w.set_data_categorical(v, [])
action = w.rename_selected_items
view = w.values_edit
model = view.model()
selmodel = view.selectionModel() # type: QItemSelectionModel
selmodel.select(
QItemSelection(model.index(0, 0), model.index(1, 0)),
QItemSelectionModel.ClearAndSelect
)
# trigger the action, then find the active popup, and simulate entry
spy = QSignalSpy(w.variable_changed)
with patch.object(QComboBox, "setVisible", return_value=None) as m:
action.trigger()
m.assert_called()
cb = view.findChild(QComboBox)
cb.setCurrentText("BA")
view.commitData(cb)
self.assertEqual(model.index(0, 0).data(Qt.EditRole), "BA")
self.assertEqual(model.index(1, 0).data(Qt.EditRole), "BA")
self.assertSequenceEqual(
list(spy), [[]], 'variable_changed should emit exactly once'
)

def test_discrete_editor_context_menu(self):
w = DiscreteVariableEditor()
v = Categorical("C", ("a", "b", "c"),
(("A", "1"), ("B", "b")), False)
w.set_data_categorical(v, [])
view = w.values_edit
model = view.model()

pos = view.visualRect(model.index(0, 0)).center()
with patch.object(QMenu, "setVisible", return_value=None) as m:
contextMenu(view.viewport(), pos)
m.assert_called()

menu = view.findChild(QMenu)
self.assertIsNotNone(menu)
menu.close()

def test_time_editor(self):
w = TimeVariableEditor()
self.assertEqual(w.get_data(), (None, []))
Expand Down
23 changes: 21 additions & 2 deletions Orange/widgets/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

from AnyQt.QtCore import Qt, QObject, QEventLoop, QTimer, QLocale, QPoint
from AnyQt.QtTest import QTest
from AnyQt.QtGui import QMouseEvent
from AnyQt.QtWidgets import QApplication
from AnyQt.QtGui import QMouseEvent, QContextMenuEvent
from AnyQt.QtWidgets import QApplication, QWidget

from Orange.data import Table, Domain, ContinuousVariable

Expand Down Expand Up @@ -323,6 +323,25 @@ def mouseMove(widget, pos=QPoint(), delay=-1): # pragma: no-cover
QApplication.sendEvent(widget, me)


def contextMenu(
widget: QWidget, pos=QPoint(), reason=QContextMenuEvent.Mouse,
modifiers=Qt.NoModifier, delay=-1
) -> None:
"""
Simulate a context menu event on `widget`.

`pos` is the event origin specified in widget's local coordinates. If not
specified. Then widget.rect().center() is used instead.
"""
if pos.isNull():
pos = widget.rect().center()
globalPos = widget.mapToGlobal(pos)
ev = QContextMenuEvent(reason, pos, globalPos, modifiers)
if delay >= 0:
QTest.qWait(delay)
QApplication.sendEvent(widget, ev)


def table_dense_sparse(test_case):
# type: (Callable) -> Callable
"""Run a single test case on both dense and sparse Orange tables.
Expand Down