From f36692638c41d549519507fbf9dc875cbd8482ae Mon Sep 17 00:00:00 2001 From: David Nadlinger Date: Thu, 25 Jun 2020 16:38:27 +0100 Subject: [PATCH] dashboard: Add "Quick Open" dialog for experiments on global shortcut This is similar to functionality in Sublime Text, VS Code, etc. --- RELEASE_NOTES.rst | 6 +- artiq/dashboard/experiments.py | 75 ++++++++- artiq/gui/fuzzy_select.py | 272 +++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 artiq/gui/fuzzy_select.py diff --git a/RELEASE_NOTES.rst b/RELEASE_NOTES.rst index bb287f4a5..bdc55724d 100644 --- a/RELEASE_NOTES.rst +++ b/RELEASE_NOTES.rst @@ -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: diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index 57aaf6af6..d4a7d788a 100644 --- a/artiq/dashboard/experiments.py +++ b/artiq/dashboard/experiments.py @@ -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() diff --git a/artiq/gui/fuzzy_select.py b/artiq/gui/fuzzy_select.py new file mode 100644 index 000000000..abbe31dae --- /dev/null +++ b/artiq/gui/fuzzy_select.py @@ -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 ' 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 " 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