Skip to content

Commit 9f25898

Browse files
authored
Merge pull request #4949 from ales-erjavec/fixes/oweditdomain-merge
[FIX] Edit Domain: Multiple item rename/merge
2 parents 740409b + c45e0a3 commit 9f25898

File tree

3 files changed

+202
-26
lines changed

3 files changed

+202
-26
lines changed

Orange/widgets/data/oweditdomain.py

Lines changed: 135 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from functools import singledispatch, partial
1414
from typing import (
1515
Tuple, List, Any, Optional, Union, Dict, Sequence, Iterable, NamedTuple,
16-
FrozenSet, Type, Callable, TypeVar, Mapping, Hashable
16+
FrozenSet, Type, Callable, TypeVar, Mapping, Hashable, cast
1717
)
1818

1919
import numpy as np
@@ -23,10 +23,13 @@
2323
QLineEdit, QAction, QActionGroup, QGroupBox,
2424
QStyledItemDelegate, QStyleOptionViewItem, QStyle, QSizePolicy,
2525
QDialogButtonBox, QPushButton, QCheckBox, QComboBox, QStackedLayout,
26-
QDialog, QRadioButton, QGridLayout, QLabel, QSpinBox, QDoubleSpinBox)
26+
QDialog, QRadioButton, QGridLayout, QLabel, QSpinBox, QDoubleSpinBox,
27+
QAbstractItemView, QMenu
28+
)
2729
from AnyQt.QtGui import QStandardItemModel, QStandardItem, QKeySequence, QIcon
2830
from AnyQt.QtCore import (
29-
Qt, QSize, QModelIndex, QAbstractItemModel, QPersistentModelIndex
31+
Qt, QSize, QModelIndex, QAbstractItemModel, QPersistentModelIndex, QRect,
32+
QPoint,
3033
)
3134
from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot
3235

@@ -36,6 +39,7 @@
3639
from Orange.widgets import widget, gui, settings
3740
from Orange.widgets.utils import itemmodels
3841
from Orange.widgets.utils.buttons import FixedSizeButton
42+
from Orange.widgets.utils.itemmodels import signal_blocking
3943
from Orange.widgets.utils.widgetpreview import WidgetPreview
4044
from Orange.widgets.utils.state_summary import format_summary_details
4145
from Orange.widgets.widget import Input, Output
@@ -466,8 +470,6 @@ def get_dict(self):
466470
return rval
467471

468472

469-
470-
471473
class VariableEditor(QWidget):
472474
"""
473475
An editor widget for a variable.
@@ -997,6 +999,14 @@ def keyRoles(self): # type: () -> FrozenSet[int]
997999
return frozenset({Qt.EditRole, EditStateRole})
9981000

9991001

1002+
def mapRectTo(widget: QWidget, parent: QWidget, rect: QRect) -> QRect: # pylint: disable=redefined-outer-name
1003+
return QRect(widget.mapTo(parent, rect.topLeft()), rect.size())
1004+
1005+
1006+
def mapRectToGlobal(widget: QWidget, rect: QRect) -> QRect: # pylint: disable=redefined-outer-name
1007+
return QRect(widget.mapToGlobal(rect.topLeft()), rect.size())
1008+
1009+
10001010
class CategoriesEditDelegate(QStyledItemDelegate):
10011011
"""
10021012
Display delegate for editing categories.
@@ -1028,6 +1038,64 @@ def initStyleOption(self, option, index):
10281038
text = text + " " + suffix
10291039
option.text = text
10301040

1041+
class CatEditComboBox(QComboBox):
1042+
prows: List[QPersistentModelIndex]
1043+
1044+
def createEditor(
1045+
self, parent: QWidget, option: 'QStyleOptionViewItem',
1046+
index: QModelIndex
1047+
) -> QWidget:
1048+
view = option.widget
1049+
assert isinstance(view, QAbstractItemView)
1050+
selmodel = view.selectionModel()
1051+
rows = selmodel.selectedRows(0)
1052+
if len(rows) < 2:
1053+
return super().createEditor(parent, option, index)
1054+
# edit multiple selection
1055+
cb = CategoriesEditDelegate.CatEditComboBox(
1056+
editable=True, insertPolicy=QComboBox.InsertAtBottom)
1057+
cb.setParent(view, Qt.Popup)
1058+
cb.addItems(
1059+
list(unique(str(row.data(Qt.EditRole)) for row in rows)))
1060+
prows = [QPersistentModelIndex(row) for row in rows]
1061+
cb.prows = prows
1062+
return cb
1063+
1064+
def updateEditorGeometry(
1065+
self, editor: QWidget, option: 'QStyleOptionViewItem',
1066+
index: QModelIndex
1067+
) -> None:
1068+
if isinstance(editor, CategoriesEditDelegate.CatEditComboBox):
1069+
view = cast(QAbstractItemView, option.widget)
1070+
view.scrollTo(index)
1071+
vport = view.viewport()
1072+
vrect = view.visualRect(index)
1073+
vrect = mapRectTo(vport, view, vrect)
1074+
vrect = vrect.intersected(vport.geometry())
1075+
vrect = mapRectToGlobal(vport, vrect)
1076+
size = editor.sizeHint().expandedTo(vrect.size())
1077+
editor.resize(size)
1078+
editor.move(vrect.topLeft())
1079+
else:
1080+
super().updateEditorGeometry(editor, option, index)
1081+
1082+
def setModelData(
1083+
self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex
1084+
) -> None:
1085+
if isinstance(editor, CategoriesEditDelegate.CatEditComboBox):
1086+
text = editor.currentText()
1087+
with signal_blocking(model):
1088+
for prow in editor.prows:
1089+
if prow.isValid():
1090+
model.setData(QModelIndex(prow), text, Qt.EditRole)
1091+
# this could be better
1092+
model.dataChanged.emit(
1093+
model.index(0, 0), model.index(model.rowCount() - 1, 0),
1094+
(Qt.EditRole,)
1095+
)
1096+
else:
1097+
super().setModelData(editor, model, index)
1098+
10311099

10321100
class DiscreteVariableEditor(VariableEditor):
10331101
"""An editor widget for editing a discrete variable.
@@ -1069,7 +1137,8 @@ def __init__(self, *args, **kwargs):
10691137
self, objectName="action-group-categories", enabled=False
10701138
)
10711139
self.move_value_up = QAction(
1072-
"\N{UPWARDS ARROW}", group,
1140+
"Move up", group,
1141+
iconText="\N{UPWARDS ARROW}",
10731142
toolTip="Move the selected item up.",
10741143
shortcut=QKeySequence(Qt.ControlModifier | Qt.AltModifier |
10751144
Qt.Key_BracketLeft),
@@ -1078,7 +1147,8 @@ def __init__(self, *args, **kwargs):
10781147
self.move_value_up.triggered.connect(self.move_up)
10791148

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

10891159
self.add_new_item = QAction(
1090-
"+", group,
1160+
"Add", group,
1161+
iconText="+",
10911162
objectName="action-add-item",
10921163
toolTip="Append a new item.",
10931164
shortcut=QKeySequence(QKeySequence.New),
10941165
shortcutContext=Qt.WidgetShortcut,
10951166
)
10961167
self.remove_item = QAction(
1097-
"\N{MINUS SIGN}", group,
1168+
"Remove item", group,
1169+
iconText="\N{MINUS SIGN}",
10981170
objectName="action-remove-item",
10991171
toolTip="Delete the selected item.",
11001172
shortcut=QKeySequence(QKeySequence.Delete),
11011173
shortcutContext=Qt.WidgetShortcut,
11021174
)
1103-
self.merge_items = QAction(
1104-
"M", group,
1105-
objectName="action-merge-item",
1106-
toolTip="Merge selected items.",
1175+
self.rename_selected_items = QAction(
1176+
"Rename selected items", group,
1177+
iconText="=",
1178+
objectName="action-rename-selected-items",
1179+
toolTip="Rename selected items.",
11071180
shortcut=QKeySequence(Qt.ControlModifier | Qt.Key_Equal),
1181+
shortcutContext=Qt.WidgetShortcut,
1182+
)
1183+
self.merge_items = QAction(
1184+
"Merge", group,
1185+
iconText="M",
1186+
objectName="action-activate-merge-dialog",
1187+
toolTip="Merge infrequent items.",
1188+
shortcut=QKeySequence(Qt.ControlModifier | Qt.MetaModifier | Qt.Key_Equal),
11081189
shortcutContext=Qt.WidgetShortcut
11091190
)
11101191

11111192
self.add_new_item.triggered.connect(self._add_category)
11121193
self.remove_item.triggered.connect(self._remove_category)
1194+
self.rename_selected_items.triggered.connect(self._rename_selected_categories)
11131195
self.merge_items.triggered.connect(self._merge_categories)
11141196

11151197
button1 = FixedSizeButton(
@@ -1129,18 +1211,37 @@ def __init__(self, *args, **kwargs):
11291211
accessibleName="Remove"
11301212
)
11311213
button5 = FixedSizeButton(
1214+
self, defaultAction=self.rename_selected_items,
1215+
accessibleName="Merge selected items"
1216+
)
1217+
button6 = FixedSizeButton(
11321218
self, defaultAction=self.merge_items,
1133-
accessibleName="Merge",
1219+
accessibleName="Merge infrequent",
11341220
)
1135-
self.values_edit.addActions([self.move_value_up, self.move_value_down,
1136-
self.add_new_item, self.remove_item])
1221+
1222+
self.values_edit.addActions([
1223+
self.move_value_up, self.move_value_down,
1224+
self.add_new_item, self.remove_item, self.rename_selected_items
1225+
])
1226+
self.values_edit.setContextMenuPolicy(Qt.CustomContextMenu)
1227+
1228+
def context_menu(pos: QPoint):
1229+
viewport = self.values_edit.viewport()
1230+
menu = QMenu(self.values_edit)
1231+
menu.setAttribute(Qt.WA_DeleteOnClose)
1232+
menu.addActions([self.rename_selected_items, self.remove_item])
1233+
menu.popup(viewport.mapToGlobal(pos))
1234+
self.values_edit.customContextMenuRequested.connect(context_menu)
1235+
11371236
hlayout.addWidget(button1)
11381237
hlayout.addWidget(button2)
11391238
hlayout.addSpacing(3)
11401239
hlayout.addWidget(button3)
11411240
hlayout.addWidget(button4)
11421241
hlayout.addSpacing(3)
11431242
hlayout.addWidget(button5)
1243+
hlayout.addWidget(button6)
1244+
11441245
hlayout.addStretch(10)
11451246
vlayout.addLayout(hlayout)
11461247

@@ -1151,6 +1252,8 @@ def __init__(self, *args, **kwargs):
11511252
QWidget.setTabOrder(button1, button2)
11521253
QWidget.setTabOrder(button2, button3)
11531254
QWidget.setTabOrder(button3, button4)
1255+
QWidget.setTabOrder(button4, button5)
1256+
QWidget.setTabOrder(button5, button6)
11541257

11551258
def set_data(self, var, transform=()):
11561259
raise NotImplementedError
@@ -1293,13 +1396,6 @@ def on_values_changed(self):
12931396
@Slot()
12941397
def on_value_selection_changed(self):
12951398
rows = self.values_edit.selectionModel().selectedRows()
1296-
# enable merge if at least 2 selected items and the selection must not
1297-
# contain any added/dropped items.
1298-
enable_merge = \
1299-
len(rows) > 1 and \
1300-
not any(index.data(EditStateRole) != ItemEditState.NoState
1301-
for index in rows)
1302-
13031399
if len(rows) == 1:
13041400
i = rows[0].row()
13051401
self.move_value_up.setEnabled(i != 0)
@@ -1422,6 +1518,22 @@ def complete_merge(text, merge_attributes):
14221518
dlg.get_merged_value_name(), dlg.get_merge_attributes()
14231519
)
14241520

1521+
def _rename_selected_categories(self):
1522+
"""
1523+
Rename selected categories and merging them.
1524+
1525+
Popup an editable combo box for selection/edit of a new value.
1526+
"""
1527+
view = self.values_edit
1528+
selmodel = view.selectionModel()
1529+
index = view.currentIndex()
1530+
if not selmodel.isSelected(index):
1531+
indices = selmodel.selectedRows(0)
1532+
if indices:
1533+
index = indices[0]
1534+
# delegate to the CategoriesEditDelegate
1535+
view.edit(index)
1536+
14251537

14261538
class ContinuousVariableEditor(VariableEditor):
14271539
# TODO: enable editing of display format...

Orange/widgets/data/tests/test_oweditdomain.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from AnyQt.QtCore import QItemSelectionModel, Qt, QItemSelection
1414
from AnyQt.QtWidgets import QAction, QComboBox, QLineEdit, \
15-
QStyleOptionViewItem, QDialog
15+
QStyleOptionViewItem, QDialog, QMenu
1616
from AnyQt.QtTest import QTest, QSignalSpy
1717

1818
from Orange.widgets.utils import colorpalettes
@@ -38,6 +38,7 @@
3838
GroupItemsDialog)
3939
from Orange.widgets.data.owcolor import OWColor, ColorRole
4040
from Orange.widgets.tests.base import WidgetTest, GuiTest
41+
from Orange.widgets.tests.utils import contextMenu
4142
from Orange.tests import test_filename, assert_array_nanequal
4243
from Orange.widgets.utils.state_summary import format_summary_details
4344

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

521+
def test_discrete_editor_rename_selected_items_action(self):
522+
w = DiscreteVariableEditor()
523+
v = Categorical("C", ("a", "b", "c"),
524+
(("A", "1"), ("B", "b")), False)
525+
w.set_data_categorical(v, [])
526+
action = w.rename_selected_items
527+
view = w.values_edit
528+
model = view.model()
529+
selmodel = view.selectionModel() # type: QItemSelectionModel
530+
selmodel.select(
531+
QItemSelection(model.index(0, 0), model.index(1, 0)),
532+
QItemSelectionModel.ClearAndSelect
533+
)
534+
# trigger the action, then find the active popup, and simulate entry
535+
spy = QSignalSpy(w.variable_changed)
536+
with patch.object(QComboBox, "setVisible", return_value=None) as m:
537+
action.trigger()
538+
m.assert_called()
539+
cb = view.findChild(QComboBox)
540+
cb.setCurrentText("BA")
541+
view.commitData(cb)
542+
self.assertEqual(model.index(0, 0).data(Qt.EditRole), "BA")
543+
self.assertEqual(model.index(1, 0).data(Qt.EditRole), "BA")
544+
self.assertSequenceEqual(
545+
list(spy), [[]], 'variable_changed should emit exactly once'
546+
)
547+
548+
def test_discrete_editor_context_menu(self):
549+
w = DiscreteVariableEditor()
550+
v = Categorical("C", ("a", "b", "c"),
551+
(("A", "1"), ("B", "b")), False)
552+
w.set_data_categorical(v, [])
553+
view = w.values_edit
554+
model = view.model()
555+
556+
pos = view.visualRect(model.index(0, 0)).center()
557+
with patch.object(QMenu, "setVisible", return_value=None) as m:
558+
contextMenu(view.viewport(), pos)
559+
m.assert_called()
560+
561+
menu = view.findChild(QMenu)
562+
self.assertIsNotNone(menu)
563+
menu.close()
564+
520565
def test_time_editor(self):
521566
w = TimeVariableEditor()
522567
self.assertEqual(w.get_data(), (None, []))

Orange/widgets/tests/utils.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
from AnyQt.QtCore import Qt, QObject, QEventLoop, QTimer, QLocale, QPoint
88
from AnyQt.QtTest import QTest
9-
from AnyQt.QtGui import QMouseEvent
10-
from AnyQt.QtWidgets import QApplication
9+
from AnyQt.QtGui import QMouseEvent, QContextMenuEvent
10+
from AnyQt.QtWidgets import QApplication, QWidget
1111

1212
from Orange.data import Table, Domain, ContinuousVariable
1313

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

325325

326+
def contextMenu(
327+
widget: QWidget, pos=QPoint(), reason=QContextMenuEvent.Mouse,
328+
modifiers=Qt.NoModifier, delay=-1
329+
) -> None:
330+
"""
331+
Simulate a context menu event on `widget`.
332+
333+
`pos` is the event origin specified in widget's local coordinates. If not
334+
specified. Then widget.rect().center() is used instead.
335+
"""
336+
if pos.isNull():
337+
pos = widget.rect().center()
338+
globalPos = widget.mapToGlobal(pos)
339+
ev = QContextMenuEvent(reason, pos, globalPos, modifiers)
340+
if delay >= 0:
341+
QTest.qWait(delay)
342+
QApplication.sendEvent(widget, ev)
343+
344+
326345
def table_dense_sparse(test_case):
327346
# type: (Callable) -> Callable
328347
"""Run a single test case on both dense and sparse Orange tables.

0 commit comments

Comments
 (0)