mirror of https://github.com/m-labs/artiq.git
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()`
|
* Zotino now exposes `voltage_to_mu()`
|
||||||
* `ad9910`: The maximum amplitude scale factor is now `0x3fff` (was `0x3ffe`
|
* `ad9910`: The maximum amplitude scale factor is now `0x3fff` (was `0x3ffe`
|
||||||
before).
|
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.
|
* Experiment results are now always saved to HDF5, even if run() fails.
|
||||||
|
|
||||||
Breaking changes:
|
Breaking changes:
|
||||||
|
|
|
@ -9,8 +9,9 @@ import h5py
|
||||||
|
|
||||||
from sipyco import pyon
|
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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -488,6 +489,60 @@ class _ExperimentDock(QtWidgets.QMdiSubWindow):
|
||||||
self.hdf5_load_directory = state["hdf5_load_directory"]
|
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:
|
class ExperimentManager:
|
||||||
def __init__(self, main_window,
|
def __init__(self, main_window,
|
||||||
explist_sub, schedule_sub,
|
explist_sub, schedule_sub,
|
||||||
|
@ -508,6 +563,13 @@ class ExperimentManager:
|
||||||
|
|
||||||
self.open_experiments = dict()
|
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):
|
def set_explist_model(self, model):
|
||||||
self.explist = model.backing_store
|
self.explist = model.backing_store
|
||||||
|
|
||||||
|
@ -708,3 +770,14 @@ class ExperimentManager:
|
||||||
self.submission_arguments = state["arguments"]
|
self.submission_arguments = state["arguments"]
|
||||||
for expurl in state["open_docks"]:
|
for expurl in state["open_docks"]:
|
||||||
self.open_experiment(expurl)
|
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