Skip to content

Commit 97f5a0c

Browse files
authored
Merge pull request #3112 from irgolic/smart-widget-suggestions
[ENH] Smart widget suggestions
2 parents c385e04 + ac58598 commit 97f5a0c

File tree

4 files changed

+211
-2
lines changed

4 files changed

+211
-2
lines changed

Orange/canvas/document/interactions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def __init__(self, document, parent=None, deleteOnEnd=True):
8080
self.document = document
8181
self.scene = document.scene()
8282
self.scheme = document.scheme()
83+
self.suggestions = document.suggestions()
8384
self.deleteOnEnd = deleteOnEnd
8485

8586
self.cancelOnEsc = False
@@ -428,6 +429,7 @@ def mouseReleaseEvent(self, event):
428429
else:
429430
source_node = node
430431
sink_node = self.scene.node_for_item(self.sink_item)
432+
self.suggestions.set_direction(self.direction)
431433
self.connect_nodes(source_node, sink_node)
432434

433435
if not self.isCanceled() or not self.isFinished() and \
@@ -458,6 +460,15 @@ def is_compatible(source, sink):
458460
if self.direction == self.FROM_SINK:
459461
# Reverse the argument order.
460462
is_compatible = reversed_arguments(is_compatible)
463+
suggestion_sort = self.suggestions.get_source_suggestions(from_desc.name)
464+
else:
465+
suggestion_sort = self.suggestions.get_sink_suggestions(from_desc.name)
466+
467+
def sort(left, right):
468+
# list stores frequencies, so sign is flipped
469+
return suggestion_sort[left] > suggestion_sort[right]
470+
471+
menu.setSortingFunc(sort)
461472

462473
def filter(index):
463474
desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE)
@@ -776,6 +787,15 @@ def create_new(self, pos, search_text=""):
776787
menu = self.document.quickMenu()
777788
menu.setFilterFunc(None)
778789

790+
# compares probability of the user needing the widget as a source
791+
def defaultSort(left, right):
792+
default_suggestions = self.suggestions.get_default_suggestions()
793+
left_frequency = sum(default_suggestions[left].values())
794+
right_frequency = sum(default_suggestions[right].values())
795+
return left_frequency > right_frequency
796+
797+
menu.setSortingFunc(defaultSort)
798+
779799
action = menu.exec_(pos, search_text)
780800
if action:
781801
item = action.property("item")

Orange/canvas/document/quickmenu.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ def setFilterRegExp(self, pattern):
279279
"""
280280
filter_proxy = self.view().model()
281281
filter_proxy.setFilterRegExp(pattern)
282+
283+
# re-sorts to make sure items that match by title are on top
284+
filter_proxy.invalidate()
285+
filter_proxy.sort(0)
286+
282287
self.ensureCurrent()
283288

284289
def setFilterWildCard(self, pattern):
@@ -296,17 +301,25 @@ def setFilterFunc(self, func):
296301
filter_proxy = self.view().model()
297302
filter_proxy.setFilterFunc(func)
298303

304+
def setSortingFunc(self, func):
305+
"""
306+
Set a sorting function.
307+
"""
308+
filter_proxy = self.view().model()
309+
filter_proxy.setSortingFunc(func)
310+
299311

300312
class SortFilterProxyModel(QSortFilterProxyModel):
301313
"""
302-
An filter proxy model used to filter items based on a filtering
303-
function.
314+
An filter proxy model used to sort and filter items based on
315+
a sort and filtering function.
304316
305317
"""
306318
def __init__(self, parent=None):
307319
QSortFilterProxyModel.__init__(self, parent)
308320

309321
self.__filterFunc = None
322+
self.__sortingFunc = None
310323

311324
def setFilterFunc(self, func):
312325
"""
@@ -344,6 +357,32 @@ def filterAcceptsRow(self, row, parent=QModelIndex()):
344357
else:
345358
return accepted
346359

360+
def setSortingFunc(self, func):
361+
self.__sortingFunc = func
362+
self.invalidate()
363+
self.sort(0)
364+
365+
def sortingFunc(self):
366+
return self.__sortingFunc
367+
368+
def lessThan(self, left, right):
369+
if self.__sortingFunc is None:
370+
return QSortFilterProxyModel.lessThan(self, left, right)
371+
model = self.sourceModel()
372+
left_data = model.data(left)
373+
right_data = model.data(right)
374+
375+
flat_model = self.sourceModel()
376+
left_description = flat_model.data(left, role=QtWidgetRegistry.WIDGET_DESC_ROLE)
377+
right_description = flat_model.data(right, role=QtWidgetRegistry.WIDGET_DESC_ROLE)
378+
379+
left_matches_title = self.filterRegExp().indexIn(left_description.name) > -1
380+
right_matches_title = self.filterRegExp().indexIn(right_description.name) > -1
381+
382+
if left_matches_title != right_matches_title:
383+
return left_matches_title
384+
return self.__sortingFunc(left_data, right_data)
385+
347386

348387
class SearchWidget(LineEdit):
349388
def __init__(self, parent=None, **kwargs):
@@ -864,6 +903,7 @@ def __init__(self, parent=None, **kwargs):
864903
self.setWindowFlags(Qt.Popup)
865904

866905
self.__filterFunc = None
906+
self.__sortingFunc = None
867907

868908
self.__setupUi()
869909

@@ -1027,6 +1067,16 @@ def setModel(self, model):
10271067
self.__model = model
10281068
self.__suggestPage.setModel(model)
10291069

1070+
def setSortingFunc(self, func):
1071+
"""
1072+
Set a sorting function in the suggest (search) menu.
1073+
"""
1074+
if self.__sortingFunc != func:
1075+
self.__sortingFunc = func
1076+
for i in range(0, self.__pages.count()):
1077+
if isinstance(self.__pages.page(i), SuggestMenuPage):
1078+
self.__pages.page(i).setSortingFunc(func)
1079+
10301080
def setFilterFunc(self, func):
10311081
"""
10321082
Set a filter function.
@@ -1157,6 +1207,14 @@ def __on_textEdited(self, text):
11571207
patt.setCaseSensitivity(False)
11581208
self.__suggestPage.setFilterRegExp(patt)
11591209
self.__pages.setCurrentPage(self.__suggestPage)
1210+
self.__selectFirstIndex()
1211+
1212+
def __selectFirstIndex(self):
1213+
view = self.__pages.currentPage().view()
1214+
model = view.model()
1215+
1216+
index = model.index(0, 0)
1217+
view.setCurrentIndex(index)
11601218

11611219
def triggerSearch(self):
11621220
"""

Orange/canvas/document/schemeedit.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
from AnyQt.QtCore import pyqtProperty as Property, pyqtSignal as Signal
3636

37+
from .suggestions import Suggestions
3738
from ..registry.qt import whats_this_helper
3839
from ..gui.quickhelp import QuickHelpTipEvent
3940
from ..gui.utils import message_information, disabled
@@ -173,6 +174,8 @@ def __init__(self, parent=None, ):
173174
self.__linkMenu.addAction(self.__linkRemoveAction)
174175
self.__linkMenu.addAction(self.__linkResetAction)
175176

177+
self.__suggestions = Suggestions()
178+
176179
def __setupActions(self):
177180
self.__cleanUpAction = \
178181
QAction(self.tr("Clean Up"), self,
@@ -640,6 +643,7 @@ def setScheme(self, scheme):
640643
self.__signalManagerStateChanged)
641644

642645
self.__scheme = scheme
646+
self.__suggestions.set_scheme(self)
643647

644648
self.setPath("")
645649

@@ -728,6 +732,12 @@ def view(self):
728732
"""
729733
return self.__view
730734

735+
def suggestions(self):
736+
"""
737+
Return the widget suggestion prediction class.
738+
"""
739+
return self.__suggestions
740+
731741
def setRegistry(self, registry):
732742
# Is this method necessary?
733743
# It should be removed when the scene (items) is fixed
@@ -857,6 +867,12 @@ def removeLink(self, link):
857867
command = commands.RemoveLinkCommand(self.__scheme, link)
858868
self.__undoStack.push(command)
859869

870+
def onNewLink(self, func):
871+
"""
872+
Runs function when new link is added to current scheme.
873+
"""
874+
self.__scheme.link_added.connect(func)
875+
860876
def addAnnotation(self, annotation):
861877
"""
862878
Add `annotation` (:class:`.BaseSchemeAnnotation`) to the scheme
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import os
2+
import pickle
3+
from collections import defaultdict
4+
import logging
5+
6+
from Orange.canvas import config
7+
from .interactions import NewLinkAction
8+
9+
log = logging.getLogger(__name__)
10+
11+
12+
class Suggestions:
13+
"""
14+
Handles sorting of quick menu items when dragging a link from a widget onto empty canvas.
15+
"""
16+
class __Suggestions:
17+
def __init__(self):
18+
self.__frequencies_path = os.path.join(config.data_dir(), "widget-use-frequency.p")
19+
self.__import_factor = 0.8 # upon starting Orange, imported frequencies are reduced
20+
21+
self.__scheme = None
22+
self.__direction = None
23+
self.link_frequencies = defaultdict(int)
24+
self.source_probability = defaultdict(lambda: defaultdict(float))
25+
self.sink_probability = defaultdict(lambda: defaultdict(float))
26+
27+
if not self.load_link_frequency():
28+
self.default_link_frequency()
29+
30+
def load_link_frequency(self):
31+
if not os.path.isfile(self.__frequencies_path):
32+
return False
33+
34+
try:
35+
with open(self.__frequencies_path, "rb") as f:
36+
imported_freq = pickle.load(f)
37+
except OSError:
38+
log.warning("Failed to open widget link frequencies.")
39+
return False
40+
41+
for k, v in imported_freq.items():
42+
imported_freq[k] = self.__import_factor * v
43+
44+
self.link_frequencies = imported_freq
45+
self.overwrite_probabilities_with_frequencies()
46+
return True
47+
48+
def default_link_frequency(self):
49+
self.link_frequencies[("File", "Data Table", NewLinkAction.FROM_SOURCE)] = 3
50+
self.overwrite_probabilities_with_frequencies()
51+
52+
def overwrite_probabilities_with_frequencies(self):
53+
for link, count in self.link_frequencies.items():
54+
self.increment_probability(link[0], link[1], link[2], count)
55+
56+
def new_link(self, link):
57+
# direction is none when a widget was not added+linked via quick menu
58+
if self.__direction is None:
59+
return
60+
61+
source_id = link.source_node.description.name
62+
sink_id = link.sink_node.description.name
63+
64+
link_key = (source_id, sink_id, self.__direction)
65+
self.link_frequencies[link_key] += 1
66+
67+
self.increment_probability(source_id, sink_id, self.__direction, 1)
68+
self.write_link_frequency()
69+
70+
self.__direction = None
71+
72+
def increment_probability(self, source_id, sink_id, direction, factor):
73+
if direction == NewLinkAction.FROM_SOURCE:
74+
self.source_probability[source_id][sink_id] += factor
75+
self.sink_probability[sink_id][source_id] += factor * 0.5
76+
else: # FROM_SINK
77+
self.source_probability[source_id][sink_id] += factor * 0.5
78+
self.sink_probability[sink_id][source_id] += factor
79+
80+
def write_link_frequency(self):
81+
try:
82+
with open(self.__frequencies_path, "wb") as f:
83+
pickle.dump(self.link_frequencies, f)
84+
except OSError:
85+
log.warning("Failed to write widget link frequencies.")
86+
return
87+
88+
def set_direction(self, direction):
89+
"""
90+
When opening quick menu, before the widget is created, set the direction
91+
of creation (FROM_SINK, FROM_SOURCE).
92+
"""
93+
self.__direction = direction
94+
95+
def set_scheme(self, scheme):
96+
self.__scheme = scheme
97+
scheme.onNewLink(self.new_link)
98+
99+
def get_sink_suggestions(self, source_id):
100+
return self.source_probability[source_id]
101+
102+
def get_source_suggestions(self, sink_id):
103+
return self.sink_probability[sink_id]
104+
105+
def get_default_suggestions(self):
106+
return self.source_probability
107+
108+
instance = None
109+
110+
def __init__(self):
111+
if not Suggestions.instance:
112+
Suggestions.instance = Suggestions.__Suggestions()
113+
114+
def __getattr__(self, name):
115+
return getattr(self.instance, name)

0 commit comments

Comments
 (0)