forked from M-Labs/artiq
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:
parent
91c93e1ad8
commit
f36692638c
|
@ -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:
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue