forked from SuzanneSoy/SearchBar
-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathSearchBox.py
536 lines (481 loc) · 20.2 KB
/
SearchBox.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
import FreeCAD as App
import FreeCADGui as Gui
import os
from PySide.QtCore import (
Qt,
SIGNAL,
QSize,
QIdentityProxyModel,
QPoint,
)
from PySide.QtWidgets import (
QTabWidget,
QSlider,
QSpinBox,
QCheckBox,
QComboBox,
QLabel,
QTabWidget,
QSizePolicy,
QPushButton,
QLineEdit,
QTextEdit,
QListView,
QAbstractItemView,
QWidget,
QVBoxLayout,
QApplication,
QListWidget,
)
from PySide.QtGui import (
QIcon,
QPixmap,
QColor,
QStandardItemModel,
QShortcut,
QKeySequence,
QStandardItem,
)
from SearchBoxLight import SearchBoxLight
import Parameters_SearchBar as Parameters
genericToolIcon = QIcon(Parameters.genericToolIcon_Pixmap)
globalIgnoreFocusOut = False
# Define the translation
translate = App.Qt.translate
def easyToolTipWidget(html):
foo = QTextEdit()
foo.setReadOnly(True)
foo.setAlignment(Qt.AlignmentFlag.AlignTop)
foo.setText(html)
return foo
class SearchBox(QLineEdit):
# The following block of code is present in the lightweight proxy SearchBoxLight
"""
resultSelected = QtCore.Signal(int, int)
"""
@staticmethod
def lazyInit(self):
if self.isInitialized:
return self
getItemGroups = self.getItemGroups
getToolTip = self.getToolTip
getItemDelegate = self.getItemDelegate
maxVisibleRows = self.maxVisibleRows
# The following block of code is executed by the lightweight proxy SearchBoxLight
"""
# Call parent constructor
super(SearchBoxLight, self).__init__(parent)
# Connect signals and slots
self.textChanged.connect(self.filterModel)
# Thanks to https://saurabhg.com/programming/search-box-using-qlineedit/ for indicating a few useful options
ico = QIcon(':/icons/help-browser.svg')
#ico = QIcon(':/icons/WhatsThis.svg')
self.addAction(ico, QtGui.QLineEdit.LeadingPosition)
self.setClearButtonEnabled(True)
self.setPlaceholderText('Search tools, prefs & tree')
self.setFixedWidth(200) # needed to avoid a change of width when the clear button appears/disappears
"""
# Save arguments
# self.model = model
self.getItemGroups = getItemGroups
self.getToolTip = getToolTip
self.itemGroups = None # Will be initialized by calling getItemGroups() the first time the search box gains focus, through focusInEvent and refreshItemGroups
self.maxVisibleRows = (
maxVisibleRows # TODO: use this to compute the correct height
)
# Create proxy model
self.proxyModel = QIdentityProxyModel()
# Filtered model to which items are manually added. Store it as a property of the object instead of a local variable, to prevent grbage collection.
self.mdl = QStandardItemModel()
# self.proxyModel.setModel(self.model)
# Create list view
self.listView = QListView(self)
self.listView.setWindowFlags(Qt.WindowType.ToolTip)
self.listView.setWindowFlag(Qt.WindowType.FramelessWindowHint)
self.listView.setSelectionMode(self.listView.SelectionMode.SingleSelection)
self.listView.setModel(self.proxyModel)
self.listView.setItemDelegate(
getItemDelegate()
) # https://stackoverflow.com/a/65930408/324969
self.listView.setMouseTracking(True)
# make the QListView non-editable
self.listView.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
# Create pane for showing extra info about the currently-selected tool
# self.extraInfo = QtGui.QLabel()
self.extraInfo = QWidget()
self.extraInfo.setWindowFlags(Qt.WindowType.ToolTip)
self.extraInfo.setWindowFlag(Qt.WindowType.FramelessWindowHint)
self.extraInfo.setLayout(QVBoxLayout())
self.extraInfo.layout().setContentsMargins(0, 0, 0, 0)
self.setExtraInfoIsActive = False
self.pendingExtraInfo = None
self.currentExtraInfo = None
# Connect signals and slots
self.listView.clicked.connect(
lambda x: self.selectResult("select", x)
) # This makes all workbenches appear. TODO: findout why, a click event seems not logic
self.listView.selectionModel().selectionChanged.connect(
self.onSelectionChanged
) # This updates the details when using the keyboard
# Add custom mouse events. On windows the click events were not working for Searcbar versions 1.2.x and older.
# These events and their proxies in the SearchBorLight fixes this
self.listView.mousePressEvent = lambda event: self.proxyMousePressEvent(event)
self.listView.mouseMoveEvent = lambda event: self.proxyMouseMoveEvent(event)
self.extraInfo.leaveEvent = lambda event: self.proxyLeaveEvent(event)
# Note: should probably use the eventFilter method instead...
wdgctx = Qt.ShortcutContext.WidgetShortcut
QShortcut(
QKeySequence(Qt.Key.Key_Down), self, context=wdgctx
).activated.connect(self.listDown)
QShortcut(QKeySequence(Qt.Key.Key_Up), self, context=wdgctx).activated.connect(
self.listUp
)
QShortcut(
QKeySequence(Qt.Key.Key_PageDown), self, context=wdgctx
).activated.connect(self.listPageDown)
QShortcut(
QKeySequence(Qt.Key.Key_PageUp), self, context=wdgctx
).activated.connect(self.listPageUp)
# Home and End do not work, for some reason.
# QShortcut(QKeySequence.MoveToEndOfDocument, self, context = wdgctx).activated.connect(self.listEnd)
# QShortcut(QKeySequence.MoveToStartOfDocument, self, context = wdgctx).activated.connect(self.listStart)
# QShortcut(QKeySequence(Qt.Key.Key_End), self, context = wdgctx).activated.connect(self.listEnd)
# QShortcut(QKeySequence('Home'), self, context = wdgctx).activated.connect(self.listStart)
QShortcut(
QKeySequence(Qt.Key.Key_Enter), self, context=wdgctx
).activated.connect(self.listAccept)
QShortcut(
QKeySequence(Qt.Key.Key_Return), self, context=wdgctx
).activated.connect(self.listAccept)
QShortcut(QKeySequence("Ctrl+Return"), self, context=wdgctx).activated.connect(
self.listAcceptToggle
)
QShortcut(QKeySequence("Ctrl+Enter"), self, context=wdgctx).activated.connect(
self.listAcceptToggle
)
QShortcut(QKeySequence("Ctrl+Space"), self, context=wdgctx).activated.connect(
self.listAcceptToggle
)
QShortcut(
QKeySequence(Qt.Key.Key_Escape), self, context=wdgctx
).activated.connect(self.listCancel)
# Initialize the model with the full list (assuming the text() is empty)
# self.proxyFilterModel(self.text()) # This is done by refreshItemGroups on focusInEvent, because the initial loading from cache can take time
self.firstShowList = True
self.isInitialized = True
return self
@staticmethod
def proxyMousePressEvent(self, event):
if self.listView.underMouse():
self.selectResult(mode=None, index=self.listView.currentIndex())
else:
event.ignore()
return
@staticmethod
def proxyMouseMoveEvent(self, arg__1):
index = self.listView.indexAt(arg__1.pos())
self.listView.setCurrentIndex(index)
self.setExtraInfo(index)
# Poor attempt to circumvent a glitch where the extra info pane stays visible after pressing Return
if not self.listView.isHidden():
self.showExtraInfo()
if self.listView.isHidden():
self.hideExtraInfo()
return
@staticmethod
def refreshItemGroups(self):
self.itemGroups = self.getItemGroups()
self.proxyFilterModel(self.text())
@staticmethod
def proxyFocusInEvent(self, qFocusEvent):
# if the extrainfo is under the cursor, don't focus but only show the list
if self.extraInfo.underMouse():
self.showList()
qFocusEvent.ignore()
return
if self.firstShowList:
mdl = QStandardItemModel()
mdl.appendRow(
[
QStandardItem(
genericToolIcon,
translate(
"SearchBar", "Please wait, loading results from cache…"
),
),
QStandardItem("0"),
QStandardItem("-1"),
]
)
self.proxyModel.setSourceModel(mdl)
self.showList()
self.firstShowList = False
Gui.updateGui()
global globalIgnoreFocusOut
if not globalIgnoreFocusOut:
self.refreshItemGroups()
self.showList()
super(SearchBoxLight, self).focusInEvent(qFocusEvent)
return
@staticmethod
def proxyFocusOutEvent(self, qFocusEvent):
global globalIgnoreFocusOut
if not globalIgnoreFocusOut:
self.hideList()
# super(SearchBoxLight, self).focusOutEvent(qFocusEvent)
@staticmethod
def proxyLeaveEvent(self, qFocusEvent):
self.clearFocus()
self.hideList()
return
@staticmethod
def movementKey(self, rowUpdate):
currentIndex = self.listView.currentIndex()
self.showList()
if self.listView.isEnabled():
currentRow = currentIndex.row()
nbRows = self.listView.model().rowCount()
if nbRows > 0:
newRow = rowUpdate(currentRow, nbRows)
index = self.listView.model().index(newRow, 0)
self.listView.setCurrentIndex(index)
@staticmethod
def proxyListDown(self):
self.movementKey(lambda current, nbRows: (current + 1) % nbRows)
@staticmethod
def proxyListUp(self):
self.movementKey(lambda current, nbRows: (current - 1) % nbRows)
@staticmethod
def proxyListPageDown(self):
self.movementKey(
lambda current, nbRows: min(
current + max(1, self.maxVisibleRows / 2), nbRows - 1
)
)
@staticmethod
def proxyListPageUp(self):
self.movementKey(
lambda current, nbRows: max(current - max(1, self.maxVisibleRows / 2), 0)
)
@staticmethod
def proxyListEnd(self):
self.movementKey(lambda current, nbRows: nbRows - 1)
@staticmethod
def proxyListStart(self):
self.movementKey(lambda current, nbRows: 0)
@staticmethod
def acceptKey(self, mode):
currentIndex = self.listView.currentIndex()
self.showList()
if currentIndex.isValid():
self.selectResult(mode, currentIndex)
@staticmethod
def proxyListAccept(self):
self.acceptKey("select")
@staticmethod
def proxyListAcceptToggle(self):
self.acceptKey("toggle")
@staticmethod
def cancelKey(self):
self.hideList()
self.clearFocus()
# QKeySequence::Cancel
@staticmethod
def proxyListCancel(self):
self.cancelKey()
@staticmethod
def proxyKeyPressEvent(self, qKeyEvent):
key = qKeyEvent.key()
modifiers = qKeyEvent.modifiers()
self.showList()
if key == Qt.Key.Key_Home and modifiers & Qt.Key.CTRL != 0:
self.listStart()
elif key == Qt.Key.Key_End and modifiers & Qt.Key.CTRL != 0:
self.listEnd()
else:
super(SearchBoxLight, self).keyPressEvent(qKeyEvent)
@staticmethod
def showList(self):
self.setFloatingWidgetsGeometry()
if not self.listView.isVisible():
self.listView.show()
self.showExtraInfo()
@staticmethod
def hideList(self):
self.listView.hide()
self.hideExtraInfo()
@staticmethod
def hideExtraInfo(self):
self.extraInfo.hide()
@staticmethod
def selectResult(self, mode: None, index):
groupId = int(index.model().itemData(index.siblingAtColumn(2))[0])
self.hideList()
# TODO: allow other options, e.g. some items could act as combinators / cumulative filters
self.setText("")
self.proxyFilterModel(self.text())
# TODO: emit index relative to the base model
self.resultSelected.emit(index, groupId)
@staticmethod
def proxyFilterModel(self, userInput):
# TODO: this will cause a race condition if it is accessed while being modified
def matches(s):
return userInput.lower() in s.lower()
def filterGroup(group):
if matches(group["text"]):
# If a group matches, include the entire subtree (might need to disable this if it causes too much noise)
return group
else:
subitems = filterGroups(group["subitems"])
# if len(subitems) == 0:
# self.index = 0
if len(subitems) > 0 or matches(group["text"]):
return {
"id": group["id"],
"text": group["text"],
"icon": group["icon"],
"action": group["action"],
"toolTip": group["toolTip"],
"subitems": subitems,
}
else:
return None
def filterGroups(groups):
groups = (filterGroup(group) for group in groups)
return [group for group in groups if group is not None]
self.mdl = QStandardItemModel()
self.mdl.appendColumn([])
def addGroups(filteredGroups, depth=0):
for group in filteredGroups:
# TODO: this is not very clean, we should memorize the index from the original itemgroups
self.mdl.appendRow(
[
QStandardItem(group["icon"] or genericToolIcon, group["text"]),
QStandardItem(str(depth)),
QStandardItem(str(group["id"])),
]
)
addGroups(group["subitems"], depth + 1)
addGroups(filterGroups(self.itemGroups))
self.proxyModel.setSourceModel(self.mdl)
self.currentExtraInfo = None # Unset this so that the ExtraInfo can be updated
# TODO: try to find the already-highlighted item
indexSelect = 1
nbRows = self.listView.model().rowCount()
if nbRows > 0:
index = self.listView.model().index(indexSelect, 0)
self.listView.setCurrentIndex(index)
self.setExtraInfo(index)
else:
self.clearExtraInfo()
# self.showList()
@staticmethod
def setFloatingWidgetsGeometry(self):
def getScreenPosition(widget):
geo = widget.geometry()
parent = widget.parent()
parentPos = (
getScreenPosition(parent) if parent is not None else QPoint(0, 0)
)
return QPoint(geo.x() + parentPos.x(), geo.y() + parentPos.y())
pos = getScreenPosition(self)
siz = self.size()
screen = QApplication.screenAt(pos)
x = pos.x()
y = pos.y() + siz.height()
hint_w = self.listView.sizeHint().width()
# TODO: this can still bump into the bottom of the screen, in that case we should flip
w = max(siz.width(), hint_w)
h = 200 # TODO: set height / size here according to desired number of items
extraw = w # choose a preferred width that doesn't change all the time,
# self.extraInfo.sizeHint().width() would change for every item.
extrax = x - extraw
if screen is not None:
scr = screen.geometry()
x = min(scr.x() + scr.width() - hint_w, x)
extraleftw = x - scr.x()
extrarightw = scr.x() + scr.width() - x
# flip the extraInfo if it doesn't fit on the screen
if extraleftw < extraw and extrarightw > extraleftw:
extrax = x + w
extraw = min(extrarightw, extraw)
else:
extrax = x - extraw
extraw = min(extraleftw, extraw)
self.listView.setGeometry(x, y, w, h)
self.extraInfo.setGeometry(extrax, y, extraw, h)
@staticmethod
def proxyOnSelectionChanged(self, selected, deselected):
# The list has .setSelectionMode(QAbstractItemView.SingleSelection),
# so there is always at most one index in selected.indexes() and at most one
# index in deselected.indexes()
selected = selected.indexes()
deselected = deselected.indexes()
if len(selected) > 0:
index = selected[0]
self.setExtraInfo(index)
# Poor attempt to circumvent a glitch where the extra info pane stays visible after pressing Return
if not self.listView.isHidden():
self.showExtraInfo()
elif len(deselected) > 0:
self.hideExtraInfo()
@staticmethod
def setExtraInfo(self, index):
if self.currentExtraInfo == (index.row(), index.column(), index.model()):
# avoid useless updates of the extra info window; this also prevents segfaults when the widget
# is replaced when selecting an option from the right-click context menu
return
self.currentExtraInfo = (index.row(), index.column(), index.model())
# TODO: use an atomic swap or mutex if possible
if self.setExtraInfoIsActive:
self.pendingExtraInfo = index
# print("boom")
else:
self.setExtraInfoIsActive = True
# print("lock")
# setExtraInfo can be called multiple times while this function is running,
# so just before existing we check for the latest pending call and execute it,
# if during that second execution some other calls are made the latest of those will
# be queued by the code a few lines above this one, and the loop will continue processing
# until an iteration during which no further call was made.
while True:
groupId = str(index.model().itemData(index.siblingAtColumn(2))[0])
# TODO: move this outside of this class, probably use a single metadata
# This is a hack to allow some widgets to set the parent and recompute their size
# during their construction.
parentIsSet = False
def setParent(toolTipWidget):
nonlocal parentIsSet
parentIsSet = True
w = self.extraInfo.layout().takeAt(0)
while w:
if hasattr(w.widget(), "finalizer"):
# The 3D viewer segfaults very easily if it is used after being destroyed, and some Python/C++ interop seems to overzealously destroys some widgets, including this one, too soon?
# Ensuring that we properly detacth the 3D viewer widget before discarding its parent seems to avoid these crashes.
# print('FINALIZER')
w.widget().finalizer()
if w.widget() is not None:
w.widget().hide() # hide before detaching, or we have widgets floating as their own window that appear for a split second in some cases.
w.widget().setParent(None)
w = self.extraInfo.layout().takeAt(0)
self.extraInfo.layout().addWidget(toolTipWidget)
self.setFloatingWidgetsGeometry()
toolTipWidget = self.getToolTip(groupId, setParent)
if isinstance(toolTipWidget, str):
toolTipWidget = easyToolTipWidget(toolTipWidget)
if not parentIsSet:
setParent(toolTipWidget)
if self.pendingExtraInfo is not None:
index = self.pendingExtraInfo
self.pendingExtraInfo = None
else:
break
# print("unlock")
self.setExtraInfoIsActive = False
@staticmethod
def clearExtraInfo(self):
# TODO: just clear the contents but keep the widget visible.
self.extraInfo.hide()
@staticmethod
def showExtraInfo(self):
self.extraInfo.show()