dashboard: Add "Quick Open" dialog for experiments on global shortcut

This is similar to functionality in Sublime Text, VS Code, etc.
This commit is contained in:
David Nadlinger 2020-06-25 16:38:27 +01:00 committed by Sébastien Bourdeauducq
parent 91c93e1ad8
commit f36692638c
3 changed files with 351 additions and 2 deletions

View File

@ -15,7 +15,11 @@ Highlights:
* Zotino now exposes `voltage_to_mu()`
* `ad9910`: The maximum amplitude scale factor is now `0x3fff` (was `0x3ffe`
before).
* Applets now restart if they are running and a ccb call changes their spec
* Dashboard:
- Applets now restart if they are running and a ccb call changes their spec
- A "Quick Open" dialog to open experiments by typing part of their name can
be brought up Ctrl-P (Ctrl+Return to immediately submit the selected entry
with the default arguments).
* Experiment results are now always saved to HDF5, even if run() fails.
Breaking changes:

View File

@ -9,8 +9,9 @@ import h5py
from sipyco import pyon
from artiq.gui.tools import LayoutWidget, log_level_to_name, get_open_file_name
from artiq.gui.entries import procdesc_to_entry, ScanEntry
from artiq.gui.fuzzy_select import FuzzySelectWidget
from artiq.gui.tools import LayoutWidget, log_level_to_name, get_open_file_name
logger = logging.getLogger(__name__)
@ -488,6 +489,60 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
self.hdf5_load_directory = state["hdf5_load_directory"]
class _QuickOpenDialog(QtWidgets.QDialog):
"""Modal dialog for opening/submitting experiments from a
FuzzySelectWidget."""
closed = QtCore.pyqtSignal()
def __init__(self, manager):
super().__init__(manager.main_window)
self.setModal(True)
self.manager = manager
self.setWindowTitle("Quick open…")
layout = QtWidgets.QGridLayout(self)
layout.setSpacing(0)
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
# Find matching experiment names. Open experiments are preferred to
# matches from the repository to ease quick window switching.
open_exps = list(self.manager.open_experiments.keys())
repo_exps = set("repo:" + k
for k in self.manager.explist.keys()) - set(open_exps)
choices = [(o, 100) for o in open_exps] + [(r, 0) for r in repo_exps]
self.select_widget = FuzzySelectWidget(choices)
layout.addWidget(self.select_widget)
self.select_widget.aborted.connect(self.close)
self.select_widget.finished.connect(self._open_experiment)
font_metrics = QtGui.QFontMetrics(self.select_widget.line_edit.font())
self.select_widget.setMinimumWidth(font_metrics.averageCharWidth() * 70)
def done(self, r):
if self.select_widget:
self.select_widget.abort()
self.closed.emit()
QtWidgets.QDialog.done(self, r)
def _open_experiment(self, exp_name, modifiers):
if modifiers & QtCore.Qt.ControlModifier:
try:
self.manager.submit(exp_name)
except:
# Not all open_experiments necessarily still exist in the explist
# (e.g. if the repository has been re-scanned since).
logger.warning("failed to submit experiment '%s'",
exp_name,
exc_info=True)
else:
self.manager.open_experiment(exp_name)
self.close()
class ExperimentManager:
def __init__(self, main_window,
explist_sub, schedule_sub,
@ -508,6 +563,13 @@ class ExperimentManager:
self.open_experiments = dict()
self.is_quick_open_shown = False
quick_open_shortcut = QtWidgets.QShortcut(
QtCore.Qt.CTRL + QtCore.Qt.Key_P,
main_window)
quick_open_shortcut.setContext(QtCore.Qt.ApplicationShortcut)
quick_open_shortcut.activated.connect(self.show_quick_open)
def set_explist_model(self, model):
self.explist = model.backing_store
@ -708,3 +770,14 @@ class ExperimentManager:
self.submission_arguments = state["arguments"]
for expurl in state["open_docks"]:
self.open_experiment(expurl)
def show_quick_open(self):
if self.is_quick_open_shown:
return
self.is_quick_open_shown = True
dialog = _QuickOpenDialog(self)
def closed():
self.is_quick_open_shown = False
dialog.closed.connect(closed)
dialog.show()

272
artiq/gui/fuzzy_select.py Normal file
View File

@ -0,0 +1,272 @@
import re
from functools import partial
from typing import List, Tuple
from PyQt5 import QtCore, QtWidgets
from artiq.gui.tools import LayoutWidget
class FuzzySelectWidget(LayoutWidget):
"""Widget to select from a list of pre-defined choices by typing in a
substring match (cf. Ctrl+P "Quick Open"/"Goto anything" functions in
editors/IDEs).
"""
#: Raised when the selection process is aborted by the user (Esc, loss of
#: focus, etc.).
aborted = QtCore.pyqtSignal()
#: Raised when an entry has been selected, giving the label of the user
#: choice and any additional QEvent.modifiers() (e.g. Ctrl key pressed).
finished = QtCore.pyqtSignal(str, int)
def __init__(self,
choices: List[Tuple[str, int]] = [],
entry_count_limit: int = 10,
*args):
"""
:param choices: The choices the user can select from, given as tuples
of labels to display and an additional weight added to the
fuzzy-matching score.
:param entry_count_limit: Maximum number of entries to show.
"""
super().__init__(*args)
self.choices = choices
self.entry_count_limit = entry_count_limit
assert entry_count_limit >= 2, ("Need to allow at least two entries " +
"to show the '<n> not shown' hint")
self.line_edit = QtWidgets.QLineEdit(self)
self.layout.addWidget(self.line_edit)
line_edit_focus_filter = _FocusEventFilter(self.line_edit)
line_edit_focus_filter.focus_gained.connect(self._activate)
line_edit_focus_filter.focus_lost.connect(self._line_edit_focus_lost)
self.line_edit.installEventFilter(line_edit_focus_filter)
self.line_edit.textChanged.connect(self._update_menu)
escape_filter = _EscapeKeyFilter(self)
escape_filter.escape_pressed.connect(self.abort)
self.line_edit.installEventFilter(escape_filter)
self.menu = None
self.update_when_text_changed = True
self.menu_typing_filter = None
self.line_edit_up_down_filter = None
self.abort_when_menu_hidden = False
self.abort_when_line_edit_unfocussed = True
def set_choices(self, choices: List[Tuple[str, int]]) -> None:
"""Update the list of choices available to the user."""
self.choices = choices
if self.menu:
self._update_menu()
def _activate(self):
self.update_when_text_changed = True
if not self.menu:
# Show menu after initial layout is complete.
QtCore.QTimer.singleShot(0, self._update_menu)
def _ensure_menu(self):
if self.menu:
return
self.menu = QtWidgets.QMenu(self)
# Display menu with search results beneath line edit.
menu_pos = self.line_edit.mapToGlobal(self.line_edit.pos())
menu_pos.setY(menu_pos.y() + self.line_edit.height())
self.menu.popup(menu_pos)
self.menu.aboutToHide.connect(self._menu_hidden)
def _menu_hidden(self):
if self.abort_when_menu_hidden:
self.abort_when_menu_hidden = False
self.abort()
def _line_edit_focus_lost(self):
if self.abort_when_line_edit_unfocussed:
self.abort()
def _update_menu(self):
if not self.update_when_text_changed:
return
filtered_choices = self._filter_choices()
if not filtered_choices:
# No matches, don't display menu at all.
if self.menu:
self.abort_when_menu_hidden = False
self.menu.close()
self.menu = None
self.abort_when_line_edit_unfocussed = True
self.line_edit.setFocus()
return
# Truncate the list, leaving room for the "<n> not shown" entry.
num_omitted = 0
if len(filtered_choices) > self.entry_count_limit:
num_omitted = len(filtered_choices) - (self.entry_count_limit - 1)
filtered_choices = filtered_choices[:self.entry_count_limit - 1]
# We are going to end up with a menu shown and the line edit losing
# focus.
self.abort_when_line_edit_unfocussed = False
if self.menu:
# Hide menu temporarily to avoid re-layouting on every added item.
self.abort_when_menu_hidden = False
self.menu.hide()
self.menu.clear()
self._ensure_menu()
first_action = None
last_action = None
for choice in filtered_choices:
action = QtWidgets.QAction(choice, self.menu)
action.triggered.connect(partial(self._finish, action, choice))
action.modifiers = 0
self.menu.addAction(action)
if not first_action:
first_action = action
last_action = action
if num_omitted > 0:
action = QtWidgets.QAction("<{} not shown>".format(num_omitted),
self.menu)
action.setEnabled(False)
self.menu.addAction(action)
if self.menu_typing_filter:
self.menu.removeEventFilter(self.menu_typing_filter)
self.menu_typing_filter = _NonUpDownKeyFilter(self.menu,
self.line_edit)
self.menu.installEventFilter(self.menu_typing_filter)
if self.line_edit_up_down_filter:
self.line_edit.removeEventFilter(self.line_edit_up_down_filter)
self.line_edit_up_down_filter = _UpDownKeyFilter(
self.line_edit, self.menu, first_action, last_action)
self.line_edit.installEventFilter(self.line_edit_up_down_filter)
self.abort_when_menu_hidden = True
self.menu.show()
if first_action:
self.menu.setActiveAction(first_action)
self.menu.setFocus()
else:
self.line_edit.setFocus()
def _filter_choices(self):
"""Return a filtered and ranked list of choices based on the current
user input.
For a choice not to be filtered out, it needs to contain the entered
characters in order. Entries are further sorted by the length of the
match (i.e. preferring matches where the entered string occurrs
without interruptions), then the position of the match, and finally
lexicographically.
"""
# TODO: More SublimeText-like heuristics taking capital letters and
# punctuation into account. Also, requiring the matches to be in order
# seems to be a bit annoying in practice.
text = self.line_edit.text()
suggestions = []
# `re` seems to be the fastest way of matching this in CPython, even
# with all the wildcards.
pat = '.*?'.join(map(re.escape, text.lower()))
regex = re.compile(pat)
for label, weight in self.choices:
r = regex.search(label.lower())
if r:
suggestions.append((len(r.group()) - weight, r.start(), label))
return [x for _, _, x in sorted(suggestions)]
def _close(self):
if self.menu:
self.menu.close()
self.menu = None
self.update_when_text_changed = False
self.line_edit.clear()
def abort(self):
self._close()
self.aborted.emit()
def _finish(self, action, name):
self._close()
self.finished.emit(name, action.modifiers)
class _FocusEventFilter(QtCore.QObject):
"""Emits signals when focus is gained/lost."""
focus_gained = QtCore.pyqtSignal()
focus_lost = QtCore.pyqtSignal()
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FocusIn:
self.focus_gained.emit()
elif event.type() == QtCore.QEvent.FocusOut:
self.focus_lost.emit()
return False
class _EscapeKeyFilter(QtCore.QObject):
"""Emits a signal if the Escape key is pressed."""
escape_pressed = QtCore.pyqtSignal()
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.KeyPress:
if event.key() == QtCore.Qt.Key_Escape:
self.escape_pressed.emit()
return False
class _UpDownKeyFilter(QtCore.QObject):
"""Handles focussing the menu when pressing up/down in the line edit."""
def __init__(self, parent, menu, first_item, last_item):
super().__init__(parent)
self.menu = menu
self.first_item = first_item
self.last_item = last_item
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.KeyPress:
if event.key() == QtCore.Qt.Key_Down:
self.menu.setActiveAction(self.first_item)
self.menu.setFocus()
return True
if event.key() == QtCore.Qt.Key_Up:
self.menu.setActiveAction(self.last_item)
self.menu.setFocus()
return True
return False
class _NonUpDownKeyFilter(QtCore.QObject):
"""Forwards input while the menu is focussed to the line edit."""
def __init__(self, parent, target):
super().__init__(parent)
self.target = target
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.KeyPress:
k = event.key()
if k in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter):
action = obj.activeAction()
if action is not None:
action.modifiers = event.modifiers()
return False
if (k != QtCore.Qt.Key_Down and k != QtCore.Qt.Key_Up
and k != QtCore.Qt.Key_Enter
and k != QtCore.Qt.Key_Return):
QtWidgets.QApplication.sendEvent(self.target, event)
return True
return False