#####################################################################
# #
# __main__.py #
# #
# Copyright 2013, Monash University #
# #
# This file is part of the program runmanager, in the labscript #
# suite (see http://labscriptsuite.org), and is licensed under the #
# Simplified BSD License. See the license.txt file in the root of #
# the project for the full license. #
# #
#####################################################################
"""Runmanager GUI and supporting code
"""
import queue
import os
import sys
import labscript_utils.excepthook
# Associate app windows with OS menu shortcuts:
import desktop_app
desktop_app.set_process_appid('runmanager')
# Splash screen
from labscript_utils.splash import Splash
splash = Splash(os.path.join(os.path.dirname(__file__), 'runmanager.svg'))
splash.show()
splash.update_text('importing standard library modules')
import time
import contextlib
import subprocess
import threading
import ast
import pprint
import traceback
import signal
from pathlib import Path
splash.update_text('importing matplotlib')
# Evaluation of globals happens in a thread with the pylab module imported.
# Although we don't care about plotting, importing pylab makes Qt calls. We
# can't have that from a non main thread, so we'll just disable matplotlib's
# GUI integration:
import matplotlib
matplotlib.use('Agg')
from qtutils.qt import QtCore, QtGui, QtWidgets
from qtutils.qt.QtCore import pyqtSignal as Signal
splash.update_text('importing labscript suite modules')
from labscript_utils.ls_zprocess import zmq_get, ProcessTree, ZMQServer
from labscript_utils.labconfig import LabConfig, save_appconfig, load_appconfig
from labscript_utils.setup_logging import setup_logging
import labscript_utils.shared_drive as shared_drive
from labscript_utils import dedent
from zprocess import raise_exception_in_thread
import runmanager
import runmanager.remote
from qtutils import (
inmain,
inmain_decorator,
UiLoader,
inthread,
DisconnectContextManager,
qtlock,
)
from labscript_utils.qtwidgets.outputbox import OutputBox
import qtutils.icons
GLOBAL_MONOSPACE_FONT = "Consolas" if os.name == 'nt' else "Ubuntu Mono"
runmanager_dir = Path(__file__).absolute().parent
process_tree = ProcessTree.instance()
# Set a meaningful name for zprocess.locking's client id:
process_tree.zlock_client.set_process_name('runmanager')
[docs]
def log_if_global(g, g_list, message):
"""logs a message if the global name "g" is in "g_list"
useful if you want to print out a message inside a loop over globals,
but only for a particular global (or set of globals).
If g_list is empty, then it will use the hardcoded list below
(useful if you want to change the behaviour globally)
"""
if not isinstance(g_list, list):
g_list = [g_list]
if not g_list:
g_list = [] # add global options here
if g in g_list:
logger.info(message)
[docs]
def composite_colors(r0, g0, b0, a0, r1, g1, b1, a1):
"""composite a second colour over a first with given alpha values and return the
result"""
a0 /= 255
a1 /= 255
a = a0 + a1 - a0 * a1
r = (a1 * r1 + (1 - a1) * a0 * r0) / a
g = (a1 * g1 + (1 - a1) * a0 * g0) / a
b = (a1 * b1 + (1 - a1) * a0 * b0) / a
return [int(round(x)) for x in (r, g, b, 255 * a)]
[docs]
@inmain_decorator()
def error_dialog(message):
QtWidgets.QMessageBox.warning(app.ui, 'runmanager', message)
[docs]
@inmain_decorator()
def question_dialog(message):
reply = QtWidgets.QMessageBox.question(app.ui, 'runmanager', message,
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
return (reply == QtWidgets.QMessageBox.Yes)
[docs]
@contextlib.contextmanager
def nested(*contextmanagers):
if contextmanagers:
with contextmanagers[0]:
with nested(*contextmanagers[1:]):
yield
else:
yield
[docs]
class ItemView(object):
"""Mixin for QTableView and QTreeView that emits a custom signal leftClicked(index)
after a left click on a valid index, and doubleLeftClicked(index) (in addition) on
double click. Also has modified tab and arrow key behaviour and custom selection
highlighting."""
leftClicked = Signal(QtCore.QModelIndex)
doubleLeftClicked = Signal(QtCore.QModelIndex)
COLOR_HIGHLIGHT = "#40308CC6" # Semitransparent blue
[docs]
def __init__(self, *args):
super(ItemView, self).__init__(*args)
self._pressed_index = None
self._double_click = False
self.setAutoScroll(False)
p = self.palette()
for group in [QtGui.QPalette.Active, QtGui.QPalette.Inactive]:
p.setColor(
group,
QtGui.QPalette.Highlight,
QtGui.QColor(self.COLOR_HIGHLIGHT))
p.setColor(
group,
QtGui.QPalette.HighlightedText,
p.color(QtGui.QPalette.Active, QtGui.QPalette.Foreground)
)
self.setPalette(p)
[docs]
def mousePressEvent(self, event):
result = super(ItemView, self).mousePressEvent(event)
index = self.indexAt(event.pos())
if event.button() == QtCore.Qt.LeftButton and index.isValid():
self._pressed_index = self.indexAt(event.pos())
return result
[docs]
def leaveEvent(self, event):
result = super(ItemView, self).leaveEvent(event)
self._pressed_index = None
self._double_click = False
return result
[docs]
def mouseDoubleClickEvent(self, event):
# Ensure our left click event occurs regardless of whether it is the
# second click in a double click or not
result = super(ItemView, self).mouseDoubleClickEvent(event)
index = self.indexAt(event.pos())
if event.button() == QtCore.Qt.LeftButton and index.isValid():
self._pressed_index = self.indexAt(event.pos())
self._double_click = True
return result
[docs]
def mouseReleaseEvent(self, event):
result = super(ItemView, self).mouseReleaseEvent(event)
index = self.indexAt(event.pos())
if event.button() == QtCore.Qt.LeftButton and index.isValid() and index == self._pressed_index:
self.leftClicked.emit(index)
if self._double_click:
self.doubleLeftClicked.emit(index)
self._pressed_index = None
self._double_click = False
return result
[docs]
def keyPressEvent(self, event):
if event.key() in [QtCore.Qt.Key_Space, QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return]:
item = self.model().itemFromIndex(self.currentIndex())
if item.isEditable():
# Space/enter edits editable items:
self.edit(self.currentIndex())
else:
# Space/enter on non-editable items simulates a left click:
self.leftClicked.emit(self.currentIndex())
return super(ItemView, self).keyPressEvent(event)
[docs]
def moveCursor(self, cursor_action, keyboard_modifiers):
current_index = self.currentIndex()
current_row, current_column = current_index.row(), current_index.column()
if cursor_action == QtWidgets.QAbstractItemView.MoveUp:
return current_index.sibling(current_row - 1, current_column)
elif cursor_action == QtWidgets.QAbstractItemView.MoveDown:
return current_index.sibling(current_row + 1, current_column)
elif cursor_action == QtWidgets.QAbstractItemView.MoveLeft:
return current_index.sibling(current_row, current_column - 1)
elif cursor_action == QtWidgets.QAbstractItemView.MoveRight:
return current_index.sibling(current_row, current_column + 1)
elif cursor_action == QtWidgets.QAbstractItemView.MovePrevious:
return current_index.sibling(current_row, current_column - 1)
elif cursor_action == QtWidgets.QAbstractItemView.MoveNext:
return current_index.sibling(current_row, current_column + 1)
else:
return super(ItemView, self).moveCursor(cursor_action, keyboard_modifiers)
[docs]
class TreeView(ItemView, QtWidgets.QTreeView):
"""Treeview version of our customised ItemView"""
[docs]
def __init__(self, parent=None):
super(TreeView, self).__init__(parent)
# Set columns to their minimum size, disabling resizing. Caller may still
# configure a specific section to stretch:
self.header().setSectionResizeMode(
QtWidgets.QHeaderView.ResizeToContents
)
self.setItemDelegate(ItemDelegate(self))
[docs]
class TableView(ItemView, QtWidgets.QTableView):
"""TableView version of our customised ItemView"""
[docs]
def __init__(self, parent=None):
super(TableView, self).__init__(parent)
# Set rows and columns to the minimum size, disabling interactive resizing.
# Caller may still configure a specific column to stretch:
self.verticalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.ResizeToContents
)
self.horizontalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.ResizeToContents
)
self.horizontalHeader().sectionResized.connect(self.on_column_resized)
self.setItemDelegate(ItemDelegate(self))
self.verticalHeader().hide()
self.setShowGrid(False)
self.horizontalHeader().setHighlightSections(False)
[docs]
def on_column_resized(self, col):
for row in range(self.model().rowCount()):
self.resizeRowToContents(row)
[docs]
class AlternatingColorModel(QtGui.QStandardItemModel):
[docs]
def __init__(self, view):
QtGui.QStandardItemModel.__init__(self)
# How much darker in each channel is the alternate base color compared
# to the base color?
self.view = view
palette = view.palette()
self.normal_color = palette.color(QtGui.QPalette.Base)
self.alternate_color = palette.color(QtGui.QPalette.AlternateBase)
r, g, b, a = self.normal_color.getRgb()
alt_r, alt_g, alt_b, alt_a = self.alternate_color.getRgb()
self.delta_r = alt_r - r
self.delta_g = alt_g - g
self.delta_b = alt_b - b
self.delta_a = alt_a - a
# A cache, store brushes so we don't have to recalculate them. Is faster.
self.bg_brushes = {}
[docs]
def get_bgbrush(self, normal_brush, alternate, selected):
"""Get cell colour as a function of its ordinary colour, whether it is on an odd
row, and whether it is selected."""
normal_rgb = normal_brush.color().getRgb() if normal_brush is not None else None
try:
return self.bg_brushes[normal_rgb, alternate, selected]
except KeyError:
pass
# Get the colour of the cell with alternate row shading:
if normal_rgb is None:
# No colour has been set. Use palette colours:
if alternate:
bg_color = self.alternate_color
else:
bg_color = self.normal_color
else:
bg_color = normal_brush.color()
if alternate:
# Modify alternate rows:
r, g, b, a = normal_rgb
alt_r = min(max(r + self.delta_r, 0), 255)
alt_g = min(max(g + self.delta_g, 0), 255)
alt_b = min(max(b + self.delta_b, 0), 255)
alt_a = min(max(a + self.delta_a, 0), 255)
bg_color = QtGui.QColor(alt_r, alt_g, alt_b, alt_a)
# If parent is a TableView, we handle selection highlighting as part of the
# background colours:
if selected and isinstance(self.view, QtWidgets.QTableView):
# Overlay highlight colour:
r_s, g_s, b_s, a_s = QtGui.QColor(ItemView.COLOR_HIGHLIGHT).getRgb()
r_0, g_0, b_0, a_0 = bg_color.getRgb()
rgb = composite_colors(r_0, g_0, b_0, a_0, r_s, g_s, b_s, a_s)
bg_color = QtGui.QColor(*rgb)
brush = QtGui.QBrush(bg_color)
self.bg_brushes[normal_rgb, alternate, selected] = brush
return brush
[docs]
def data(self, index, role):
"""When background color data is being requested, returns modified colours for
every second row, according to the palette of the view. This has the effect of
making the alternate colours visible even when custom colors have been set - the
same shading will be applied to the custom colours. Only really looks sensible
when the normal and alternate colors are similar. Also applies selection
highlight colour (using ItemView.COLOR_HIGHLIGHT), similarly with alternate-row
shading, for the case of a QTableView."""
if role == QtCore.Qt.BackgroundRole:
normal_brush = QtGui.QStandardItemModel.data(self, index, QtCore.Qt.BackgroundRole)
selected = index in self.view.selectedIndexes()
alternate = index.row() % 2
return self.get_bgbrush(normal_brush, alternate, selected)
return QtGui.QStandardItemModel.data(self, index, role)
[docs]
class Editor(QtWidgets.QTextEdit):
"""Popup editor with word wrapping and automatic resizing."""
[docs]
def __init__(self, parent):
QtWidgets.QTextEdit.__init__(self, parent)
self.setWordWrapMode(QtGui.QTextOption.WordWrap)
self.setAcceptRichText(False)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.textChanged.connect(self.update_size)
self.initial_height = None
[docs]
def update_size(self):
if self.initial_height is not None:
# Temporarily shrink back to the initial height, just so that the document
# size below returns the preferred size rather than the current size.
# QTextDocument doesn't have a sizeHint of minimumSizeHint method, so this
# is the best we can do to get its minimum size.
self.setFixedHeight(self.initial_height)
preferred_height = self.document().size().toSize().height()
# Do not shrink smaller than the initial height:
if self.initial_height is not None and preferred_height >= self.initial_height:
self.setFixedHeight(preferred_height)
[docs]
def resizeEvent(self, event):
result = QtWidgets.QTextEdit.resizeEvent(self, event)
# Record the initial height after it is first set:
if self.initial_height is None:
self.initial_height = self.height()
return result
[docs]
class ItemDelegate(QtWidgets.QStyledItemDelegate):
"""An item delegate with a larger row height and column width, faint grey vertical
lines between columns, and a custom editor for handling multi-line data"""
MIN_ROW_HEIGHT = 22
EXTRA_ROW_HEIGHT = 6
EXTRA_COL_WIDTH = 20
[docs]
def __init__(self, *args, **kwargs):
QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
self._pen = QtGui.QPen()
self._pen.setWidth(1)
self._pen.setColor(QtGui.QColor.fromRgb(128, 128, 128, 64))
[docs]
def sizeHint(self, *args):
size = QtWidgets.QStyledItemDelegate.sizeHint(self, *args)
if size.height() <= self.MIN_ROW_HEIGHT:
height = self.MIN_ROW_HEIGHT
else:
# Esnure cells with multiple lines of text still have some padding:
height = size.height() + self.EXTRA_ROW_HEIGHT
return QtCore.QSize(size.width() + self.EXTRA_COL_WIDTH, height)
[docs]
def paint(self, painter, option, index):
if isinstance(self.parent(), QtWidgets.QTableView):
# Disable rendering of selection highlight for TableViews, they handle
# it themselves with the background colour data:
option.state &= ~(QtWidgets.QStyle.State_Selected)
QtWidgets.QStyledItemDelegate.paint(self, painter, option, index)
if index.column() > 0:
painter.setPen(self._pen)
painter.drawLine(option.rect.topLeft(), option.rect.bottomLeft())
[docs]
def eventFilter(self, obj, event):
"""Filter events before they get to the editor, so that editing is ended when
the user presses tab, shift-tab or enter (which otherwise would not end editing
in a QTextEdit)."""
if event.type() == QtCore.QEvent.KeyPress:
if event.key() in [QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return]:
# Allow shift-enter
if not event.modifiers() & QtCore.Qt.ShiftModifier:
self.commitData.emit(obj)
self.closeEditor.emit(obj)
return True
elif event.key() == QtCore.Qt.Key_Tab:
self.commitData.emit(obj)
self.closeEditor.emit(obj, QtWidgets.QStyledItemDelegate.EditNextItem)
return True
elif event.key() == QtCore.Qt.Key_Backtab:
self.commitData.emit(obj)
self.closeEditor.emit(obj, QtWidgets.QStyledItemDelegate.EditPreviousItem)
return True
return QtWidgets.QStyledItemDelegate.eventFilter(self, obj, event)
[docs]
def createEditor(self, parent, option, index):
return Editor(parent)
[docs]
def setEditorData(self, editor, index):
editor.setPlainText(index.data())
font = index.data(QtCore.Qt.FontRole)
default_font = qapplication.font(self.parent())
if font is None:
font = default_font
font.setPointSize(default_font.pointSize())
editor.setFont(font)
font_height = QtGui.QFontMetrics(font).height()
padding = (self.MIN_ROW_HEIGHT - font_height) / 2 - 1
editor.document().setDocumentMargin(padding)
editor.selectAll()
[docs]
def setModelData(self, editor, model, index):
model.setData(index, editor.toPlainText())
[docs]
class GroupTab(object):
GLOBALS_COL_DELETE = 0
GLOBALS_COL_NAME = 1
GLOBALS_COL_VALUE = 2
GLOBALS_COL_UNITS = 3
GLOBALS_COL_EXPANSION = 4
GLOBALS_ROLE_IS_DUMMY_ROW = QtCore.Qt.UserRole + 1
GLOBALS_ROLE_SORT_DATA = QtCore.Qt.UserRole + 2
GLOBALS_ROLE_PREVIOUS_TEXT = QtCore.Qt.UserRole + 3
GLOBALS_ROLE_IS_BOOL = QtCore.Qt.UserRole + 4
COLOR_ERROR = '#F79494' # light red
COLOR_OK = '#A5F7C6' # light green
COLOR_BOOL_ON = '#63F731' # bright green
COLOR_BOOL_OFF = '#608060' # dark green
GLOBALS_DUMMY_ROW_TEXT = '<Click to add global>'
[docs]
def __init__(self, tabWidget, globals_file, group_name):
self.tabWidget = tabWidget
loader = UiLoader()
loader.registerCustomWidget(TableView)
self.ui = loader.load(os.path.join(runmanager_dir, 'group.ui'))
# Add the ui to the parent tabWidget:
self.tabWidget.addTab(self.ui, group_name, closable=True)
self.set_file_and_group_name(globals_file, group_name)
self.globals_model = AlternatingColorModel(view=self.ui.tableView_globals)
self.globals_model.setHorizontalHeaderLabels(['Delete', 'Name', 'Value', 'Units', 'Expansion'])
self.globals_model.setSortRole(self.GLOBALS_ROLE_SORT_DATA)
self.ui.tableView_globals.setModel(self.globals_model)
self.ui.tableView_globals.setSelectionBehavior(QtWidgets.QTableView.SelectRows)
self.ui.tableView_globals.setSelectionMode(QtWidgets.QTableView.ExtendedSelection)
self.ui.tableView_globals.setSortingEnabled(True)
# Make it so the user can just start typing on an item to edit:
self.ui.tableView_globals.setEditTriggers(QtWidgets.QTableView.AnyKeyPressed |
QtWidgets.QTableView.EditKeyPressed)
# Ensure the clickable region of the delete button doesn't extend forever:
self.ui.tableView_globals.horizontalHeader().setStretchLastSection(False)
# Stretch the value column to fill available space:
self.ui.tableView_globals.horizontalHeader().setSectionResizeMode(
self.GLOBALS_COL_VALUE, QtWidgets.QHeaderView.Stretch
)
# Setup stuff for a custom context menu:
self.ui.tableView_globals.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
# Make the actions for the context menu:
self.action_globals_delete_selected = QtWidgets.QAction(
QtGui.QIcon(':qtutils/fugue/minus'), 'Delete selected global(s)', self.ui)
self.action_globals_set_selected_true = QtWidgets.QAction(
QtGui.QIcon(':qtutils/fugue/ui-check-box'), 'Set selected Booleans True', self.ui)
self.action_globals_set_selected_false = QtWidgets.QAction(
QtGui.QIcon(':qtutils/fugue/ui-check-box-uncheck'), 'Set selected Booleans False', self.ui)
self.connect_signals()
# Populate the model with globals from the h5 file:
self.populate_model()
# Set sensible column widths:
for col in range(self.globals_model.columnCount()):
if col != self.GLOBALS_COL_VALUE:
self.ui.tableView_globals.resizeColumnToContents(col)
if self.ui.tableView_globals.columnWidth(self.GLOBALS_COL_NAME) < 200:
self.ui.tableView_globals.setColumnWidth(self.GLOBALS_COL_NAME, 200)
if self.ui.tableView_globals.columnWidth(self.GLOBALS_COL_VALUE) < 200:
self.ui.tableView_globals.setColumnWidth(self.GLOBALS_COL_VALUE, 200)
if self.ui.tableView_globals.columnWidth(self.GLOBALS_COL_UNITS) < 100:
self.ui.tableView_globals.setColumnWidth(self.GLOBALS_COL_UNITS, 100)
if self.ui.tableView_globals.columnWidth(self.GLOBALS_COL_EXPANSION) < 100:
self.ui.tableView_globals.setColumnWidth(self.GLOBALS_COL_EXPANSION, 100)
self.ui.tableView_globals.resizeColumnToContents(self.GLOBALS_COL_DELETE)
# Error state of tab
self.tab_contains_errors = False
[docs]
def connect_signals(self):
self.ui.tableView_globals.leftClicked.connect(self.on_tableView_globals_leftClicked)
self.ui.tableView_globals.customContextMenuRequested.connect(self.on_tableView_globals_context_menu_requested)
self.action_globals_set_selected_true.triggered.connect(
lambda: self.on_globals_set_selected_bools_triggered('True'))
self.action_globals_set_selected_false.triggered.connect(
lambda: self.on_globals_set_selected_bools_triggered('False'))
self.action_globals_delete_selected.triggered.connect(self.on_globals_delete_selected_triggered)
self.globals_model.itemChanged.connect(self.on_globals_model_item_changed)
# A context manager with which we can temporarily disconnect the above connection.
self.globals_model_item_changed_disconnected = DisconnectContextManager(
self.globals_model.itemChanged, self.on_globals_model_item_changed)
[docs]
def set_file_and_group_name(self, globals_file, group_name):
"""Provided as a separate method so the main app can call it if the
group gets renamed"""
self.globals_file = globals_file
self.group_name = group_name
self.ui.label_globals_file.setText(globals_file)
self.ui.label_group_name.setText(group_name)
index = self.tabWidget.indexOf(self.ui)
self.tabWidget.setTabText(index, group_name)
self.tabWidget.setTabToolTip(index, '%s\n(%s)' % (group_name, globals_file))
[docs]
def set_tab_icon(self, icon_string):
index = self.tabWidget.indexOf(self.ui)
if icon_string is not None:
icon = QtGui.QIcon(icon_string)
else:
icon = QtGui.QIcon()
if self.tabWidget.tabIcon(index).cacheKey() != icon.cacheKey():
logger.info('setting tab icon')
self.tabWidget.setTabIcon(index, icon)
[docs]
def populate_model(self):
globals = runmanager.get_globals({self.group_name: self.globals_file})[self.group_name]
for name, (value, units, expansion) in globals.items():
row = self.make_global_row(name, value, units, expansion)
self.globals_model.appendRow(row)
value_item = row[self.GLOBALS_COL_VALUE]
self.check_for_boolean_values(value_item)
expansion_item = row[self.GLOBALS_COL_EXPANSION]
self.on_globals_model_expansion_changed(expansion_item)
# Add the dummy item at the end:
dummy_delete_item = QtGui.QStandardItem()
# This lets later code know that this row does not correspond to an
# actual global:
dummy_delete_item.setData(True, self.GLOBALS_ROLE_IS_DUMMY_ROW)
dummy_delete_item.setFlags(QtCore.Qt.NoItemFlags)
dummy_delete_item.setToolTip('Click to add global')
dummy_name_item = QtGui.QStandardItem(self.GLOBALS_DUMMY_ROW_TEXT)
dummy_name_item.setFont(QtGui.QFont(GLOBAL_MONOSPACE_FONT))
dummy_name_item.setToolTip('Click to add global')
dummy_name_item.setData(True, self.GLOBALS_ROLE_IS_DUMMY_ROW)
dummy_name_item.setData(self.GLOBALS_DUMMY_ROW_TEXT, self.GLOBALS_ROLE_PREVIOUS_TEXT)
dummy_name_item.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable) # Clears the 'selectable' flag
dummy_value_item = QtGui.QStandardItem()
dummy_value_item.setData(True, self.GLOBALS_ROLE_IS_DUMMY_ROW)
dummy_value_item.setFlags(QtCore.Qt.NoItemFlags)
dummy_value_item.setToolTip('Click to add global')
dummy_units_item = QtGui.QStandardItem()
dummy_units_item.setData(True, self.GLOBALS_ROLE_IS_DUMMY_ROW)
dummy_units_item.setFlags(QtCore.Qt.NoItemFlags)
dummy_units_item.setToolTip('Click to add global')
dummy_expansion_item = QtGui.QStandardItem()
dummy_expansion_item.setData(True, self.GLOBALS_ROLE_IS_DUMMY_ROW)
dummy_expansion_item.setFlags(QtCore.Qt.NoItemFlags)
dummy_expansion_item.setToolTip('Click to add global')
self.globals_model.appendRow(
[dummy_delete_item, dummy_name_item, dummy_value_item, dummy_units_item, dummy_expansion_item])
# Sort by name:
self.ui.tableView_globals.sortByColumn(self.GLOBALS_COL_NAME, QtCore.Qt.AscendingOrder)
[docs]
def make_global_row(self, name, value='', units='', expansion=''):
logger.debug('%s:%s - make global row: %s ' % (self.globals_file, self.group_name, name))
# We just set some data here, other stuff is set in
# self.update_parse_indication after runmanager has a chance to parse
# everything and get back to us about what that data should be.
delete_item = QtGui.QStandardItem()
delete_item.setIcon(QtGui.QIcon(':qtutils/fugue/minus'))
# Must be set to something so that the dummy row doesn't get sorted first:
delete_item.setData(False, self.GLOBALS_ROLE_SORT_DATA)
delete_item.setEditable(False)
delete_item.setToolTip('Delete global from group.')
name_item = QtGui.QStandardItem(name)
name_item.setData(name, self.GLOBALS_ROLE_SORT_DATA)
name_item.setData(name, self.GLOBALS_ROLE_PREVIOUS_TEXT)
name_item.setToolTip(name)
name_item.setFont(QtGui.QFont(GLOBAL_MONOSPACE_FONT))
value_item = QtGui.QStandardItem(value)
value_item.setData(value, self.GLOBALS_ROLE_SORT_DATA)
value_item.setData(str(value), self.GLOBALS_ROLE_PREVIOUS_TEXT)
value_item.setToolTip('Evaluating...')
value_item.setFont(QtGui.QFont(GLOBAL_MONOSPACE_FONT))
units_item = QtGui.QStandardItem(units)
units_item.setData(units, self.GLOBALS_ROLE_SORT_DATA)
units_item.setData(units, self.GLOBALS_ROLE_PREVIOUS_TEXT)
units_item.setData(False, self.GLOBALS_ROLE_IS_BOOL)
units_item.setToolTip('')
expansion_item = QtGui.QStandardItem(expansion)
expansion_item.setData(expansion, self.GLOBALS_ROLE_SORT_DATA)
expansion_item.setData(expansion, self.GLOBALS_ROLE_PREVIOUS_TEXT)
expansion_item.setToolTip('')
row = [delete_item, name_item, value_item, units_item, expansion_item]
return row
[docs]
def on_tableView_globals_leftClicked(self, index):
if qapplication.keyboardModifiers() != QtCore.Qt.NoModifier:
# Only handle mouseclicks with no keyboard modifiers.
return
item = self.globals_model.itemFromIndex(index)
# The 'name' item in the same row:
name_index = index.sibling(index.row(), self.GLOBALS_COL_NAME)
name_item = self.globals_model.itemFromIndex(name_index)
global_name = name_item.text()
if item.data(self.GLOBALS_ROLE_IS_DUMMY_ROW):
# They clicked on an 'add new global' row. Enter editing mode on
# the name item so they can enter a name for the new global:
self.ui.tableView_globals.setCurrentIndex(name_index)
self.ui.tableView_globals.edit(name_index)
elif item.data(self.GLOBALS_ROLE_IS_BOOL):
# It's a bool indicator. Toggle it
value_item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_VALUE)
if value_item.text() == 'True':
value_item.setText('False')
elif value_item.text() == 'False':
value_item.setText('True')
else:
raise AssertionError('expected boolean value')
elif item.column() == self.GLOBALS_COL_DELETE:
# They clicked a delete button.
self.delete_global(global_name)
elif not item.data(self.GLOBALS_ROLE_IS_BOOL):
# Edit whatever it is:
if (self.ui.tableView_globals.currentIndex() != index
or self.ui.tableView_globals.state() != QtWidgets.QTreeView.EditingState):
self.ui.tableView_globals.setCurrentIndex(index)
self.ui.tableView_globals.edit(index)
[docs]
def on_globals_model_item_changed(self, item):
if item.column() == self.GLOBALS_COL_NAME:
self.on_globals_model_name_changed(item)
elif item.column() == self.GLOBALS_COL_VALUE:
self.on_globals_model_value_changed(item)
elif item.column() == self.GLOBALS_COL_UNITS:
self.on_globals_model_units_changed(item)
elif item.column() == self.GLOBALS_COL_EXPANSION:
self.on_globals_model_expansion_changed(item)
[docs]
def on_globals_model_name_changed(self, item):
"""Handles global renaming and creation of new globals due to the user
editing the <click to add global> item"""
item_text = item.text()
if item.data(self.GLOBALS_ROLE_IS_DUMMY_ROW):
if item_text != self.GLOBALS_DUMMY_ROW_TEXT:
# The user has made a new global by editing the <click to add
# global> item
global_name = item_text
self.new_global(global_name)
else:
# User has renamed a global.
new_global_name = item_text
previous_global_name = item.data(self.GLOBALS_ROLE_PREVIOUS_TEXT)
# Ensure the name actually changed, rather than something else
# about the item:
if new_global_name != previous_global_name:
self.rename_global(previous_global_name, new_global_name)
[docs]
def on_globals_model_value_changed(self, item):
index = item.index()
new_value = item.text()
previous_value = item.data(self.GLOBALS_ROLE_PREVIOUS_TEXT)
name_index = index.sibling(index.row(), self.GLOBALS_COL_NAME)
name_item = self.globals_model.itemFromIndex(name_index)
global_name = name_item.text()
# Ensure the value actually changed, rather than something else about
# the item:
if new_value != previous_value:
self.change_global_value(global_name, previous_value, new_value)
[docs]
def on_globals_model_units_changed(self, item):
index = item.index()
new_units = item.text()
previous_units = item.data(self.GLOBALS_ROLE_PREVIOUS_TEXT)
name_index = index.sibling(index.row(), self.GLOBALS_COL_NAME)
name_item = self.globals_model.itemFromIndex(name_index)
global_name = name_item.text()
# If it's a boolean value, ensure the check state matches the bool state:
if item.data(self.GLOBALS_ROLE_IS_BOOL):
value_item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_VALUE)
if value_item.text() == 'True':
item.setCheckState(QtCore.Qt.Checked)
elif value_item.text() == 'False':
item.setCheckState(QtCore.Qt.Unchecked)
else:
raise AssertionError('expected boolean value')
# Ensure the value actually changed, rather than something else about
# the item:
if new_units != previous_units:
self.change_global_units(global_name, previous_units, new_units)
[docs]
def on_globals_model_expansion_changed(self, item):
index = item.index()
new_expansion = item.text()
previous_expansion = item.data(self.GLOBALS_ROLE_PREVIOUS_TEXT)
name_index = index.sibling(index.row(), self.GLOBALS_COL_NAME)
name_item = self.globals_model.itemFromIndex(name_index)
global_name = name_item.text()
# Don't want icon changing to recurse - which happens even if it is
# the same icon. So disconnect the signal temporarily:
with self.globals_model_item_changed_disconnected:
if new_expansion == 'outer':
item.setIcon(QtGui.QIcon(':qtutils/custom/outer'))
item.setToolTip('This global will be interpreted as a list of values, and will ' +
'be outer producted with other lists to form a larger parameter space.')
elif new_expansion:
item.setIcon(QtGui.QIcon(':qtutils/custom/zip'))
item.setToolTip('This global will be interpreted as a list of values, and will ' +
'be iterated over in lock-step with other globals in the ' +
'\'%s\' zip group.' % new_expansion)
else:
item.setData(None, QtCore.Qt.DecorationRole)
item.setToolTip('This global will be interpreted as a single value and passed to compilation as-is.')
# Ensure the value actually changed, rather than something else about
# the item:
if new_expansion != previous_expansion:
self.change_global_expansion(global_name, previous_expansion, new_expansion)
[docs]
def on_globals_delete_selected_triggered(self):
selected_indexes = self.ui.tableView_globals.selectedIndexes()
selected_items = (self.globals_model.itemFromIndex(index) for index in selected_indexes)
name_items = [item for item in selected_items if item.column() == self.GLOBALS_COL_NAME]
# If multiple selected, show 'delete n groups?' message. Otherwise,
# pass confirm=True to self.delete_global so it can show the regular
# message.
confirm_multiple = (len(name_items) > 1)
if confirm_multiple:
if not question_dialog("Delete %d globals?" % len(name_items)):
return
for item in name_items:
global_name = item.text()
self.delete_global(global_name, confirm=not confirm_multiple)
[docs]
def on_globals_set_selected_bools_triggered(self, state):
selected_indexes = self.ui.tableView_globals.selectedIndexes()
selected_items = [self.globals_model.itemFromIndex(index) for index in selected_indexes]
value_items = [item for item in selected_items if item.column() == self.GLOBALS_COL_VALUE]
units_items = [item for item in selected_items if item.column() == self.GLOBALS_COL_UNITS]
for value_item, units_item in zip(value_items, units_items):
if units_item.data(self.GLOBALS_ROLE_IS_BOOL):
value_item.setText(state)
[docs]
def close(self):
# It is up to the main runmanager class to drop references to this
# instance before or after calling this method, so that after the
# tabWidget no longer owns our widgets, both the widgets and the
# instance will be garbage collected.
index = self.tabWidget.indexOf(self.ui)
self.tabWidget.removeTab(index)
[docs]
def get_global_item_by_name(self, global_name, column, previous_name=None):
"""Returns an item from the row representing a global in the globals model.
Which item is returned is set by the column argument."""
possible_name_items = self.globals_model.findItems(global_name, column=self.GLOBALS_COL_NAME)
if previous_name is not None:
# Filter by previous name, useful for telling rows apart when a
# rename is in progress and two rows may temporarily contain the
# same name (though the rename code with throw an error and revert
# it).
possible_name_items = [item for item in possible_name_items
if item.data(self.GLOBALS_ROLE_PREVIOUS_TEXT) == previous_name]
elif global_name != self.GLOBALS_DUMMY_ROW_TEXT:
# Don't return the dummy item unless they asked for it explicitly
# - if a new global is being created, its name might be
# simultaneously present in its own row and the dummy row too.
possible_name_items = [item for item in possible_name_items
if not item.data(self.GLOBALS_ROLE_IS_DUMMY_ROW)]
if len(possible_name_items) > 1:
raise LookupError('Multiple items found')
elif not possible_name_items:
raise LookupError('No item found')
name_item = possible_name_items[0]
name_index = name_item.index()
# Found the name item, get the sibling item for the column requested:
item_index = name_index.sibling(name_index.row(), column)
item = self.globals_model.itemFromIndex(item_index)
return item
[docs]
def do_model_sort(self):
header = self.ui.tableView_globals.horizontalHeader()
sort_column = header.sortIndicatorSection()
sort_order = header.sortIndicatorOrder()
self.ui.tableView_globals.sortByColumn(sort_column, sort_order)
[docs]
def new_global(self, global_name):
logger.info('%s:%s - new global: %s', self.globals_file, self.group_name, global_name)
item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_NAME,
previous_name=self.GLOBALS_DUMMY_ROW_TEXT)
try:
runmanager.new_global(self.globals_file, self.group_name, global_name)
except Exception as e:
error_dialog(str(e))
else:
# Insert the newly created global into the model:
global_row = self.make_global_row(global_name)
last_index = self.globals_model.rowCount()
# Insert it as the row before the last (dummy) row:
self.globals_model.insertRow(last_index - 1, global_row)
self.do_model_sort()
# Go into edit mode on the 'value' item:
value_item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_VALUE,
previous_name=global_name)
value_item_index = value_item.index()
self.ui.tableView_globals.setCurrentIndex(value_item_index)
self.ui.tableView_globals.edit(value_item_index)
self.globals_changed()
finally:
# Set the dummy row's text back ready for another group to be created:
item.setText(self.GLOBALS_DUMMY_ROW_TEXT)
[docs]
def rename_global(self, previous_global_name, new_global_name):
logger.info('%s:%s - rename global: %s -> %s',
self.globals_file, self.group_name, previous_global_name, new_global_name)
item = self.get_global_item_by_name(new_global_name, self.GLOBALS_COL_NAME,
previous_name=previous_global_name)
try:
runmanager.rename_global(self.globals_file, self.group_name, previous_global_name, new_global_name)
except Exception as e:
error_dialog(str(e))
# Set the item text back to the old name, since the rename failed:
item.setText(previous_global_name)
else:
item.setData(new_global_name, self.GLOBALS_ROLE_PREVIOUS_TEXT)
item.setData(new_global_name, self.GLOBALS_ROLE_SORT_DATA)
self.do_model_sort()
item.setToolTip(new_global_name)
self.globals_changed()
value_item = self.get_global_item_by_name(new_global_name, self.GLOBALS_COL_VALUE)
value = value_item.text()
if not value and self.ui.tableView_globals.state() != QtWidgets.QAbstractItemView.EditingState:
# Go into editing the value item automatically if not already in edit mode:
value_item_index = value_item.index()
self.ui.tableView_globals.setCurrentIndex(value_item_index)
self.ui.tableView_globals.edit(value_item_index)
else:
# If this changed the sort order, ensure the item is still visible:
scroll_view_to_row_if_current(self.ui.tableView_globals, item)
[docs]
def change_global_value(self, global_name, previous_value, new_value, interactive=True):
logger.info('%s:%s - change global value: %s = %s -> %s' %
(self.globals_file, self.group_name, global_name, previous_value, new_value))
item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_VALUE)
if not interactive:
# Value was not set interactively by the user, it is up to us to set it:
with self.globals_model_item_changed_disconnected:
item.setText(new_value)
previous_background = item.background()
previous_icon = item.icon()
item.setData(new_value, self.GLOBALS_ROLE_PREVIOUS_TEXT)
item.setData(new_value, self.GLOBALS_ROLE_SORT_DATA)
item.setData(None, QtCore.Qt.BackgroundRole)
item.setIcon(QtGui.QIcon(':qtutils/fugue/hourglass'))
args = global_name, previous_value, new_value, item, previous_background, previous_icon
if interactive:
QtCore.QTimer.singleShot(1, lambda: self.complete_change_global_value(*args))
else:
self.complete_change_global_value(*args, interactive=False)
[docs]
def complete_change_global_value(self, global_name, previous_value, new_value, item, previous_background, previous_icon, interactive=True):
try:
runmanager.set_value(self.globals_file, self.group_name, global_name, new_value)
except Exception as e:
if interactive:
error_dialog(str(e))
# Set the item text back to the old name, since the change failed:
with self.globals_model_item_changed_disconnected:
item.setText(previous_value)
item.setData(previous_value, self.GLOBALS_ROLE_PREVIOUS_TEXT)
item.setData(previous_value, self.GLOBALS_ROLE_SORT_DATA)
item.setData(previous_background, QtCore.Qt.BackgroundRole)
item.setIcon(previous_icon)
if not interactive:
raise
else:
self.check_for_boolean_values(item)
self.do_model_sort()
item.setToolTip('Evaluating...')
self.globals_changed()
if not interactive:
return
units_item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_UNITS)
units = units_item.text()
if not units and self.ui.tableView_globals.state() != QtWidgets.QAbstractItemView.EditingState:
# Go into editing the units item automatically if not already in edit mode:
units_item_index = units_item.index()
self.ui.tableView_globals.setCurrentIndex(units_item_index)
self.ui.tableView_globals.edit(units_item_index)
else:
# If this changed the sort order, ensure the item is still visible:
scroll_view_to_row_if_current(self.ui.tableView_globals, item)
[docs]
def change_global_units(self, global_name, previous_units, new_units):
logger.info('%s:%s - change units: %s = %s -> %s' %
(self.globals_file, self.group_name, global_name, previous_units, new_units))
item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_UNITS)
try:
runmanager.set_units(self.globals_file, self.group_name, global_name, new_units)
except Exception as e:
error_dialog(str(e))
# Set the item text back to the old units, since the change failed:
item.setText(previous_units)
else:
item.setData(new_units, self.GLOBALS_ROLE_PREVIOUS_TEXT)
item.setData(new_units, self.GLOBALS_ROLE_SORT_DATA)
self.do_model_sort()
# If this changed the sort order, ensure the item is still visible:
scroll_view_to_row_if_current(self.ui.tableView_globals, item)
[docs]
def change_global_expansion(self, global_name, previous_expansion, new_expansion):
logger.info('%s:%s - change expansion: %s = %s -> %s' %
(self.globals_file, self.group_name, global_name, previous_expansion, new_expansion))
item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_EXPANSION)
try:
runmanager.set_expansion(self.globals_file, self.group_name, global_name, new_expansion)
except Exception as e:
error_dialog(str(e))
# Set the item text back to the old units, since the change failed:
item.setText(previous_expansion)
else:
item.setData(new_expansion, self.GLOBALS_ROLE_PREVIOUS_TEXT)
item.setData(new_expansion, self.GLOBALS_ROLE_SORT_DATA)
self.do_model_sort()
self.globals_changed()
# If this changed the sort order, ensure the item is still visible:
scroll_view_to_row_if_current(self.ui.tableView_globals, item)
[docs]
def check_for_boolean_values(self, item):
"""Checks if the value is 'True' or 'False'. If either, makes the
units cell checkable, uneditable, and coloured to indicate the state.
The units cell can then be clicked to toggle the value."""
index = item.index()
value = item.text()
name_index = index.sibling(index.row(), self.GLOBALS_COL_NAME)
units_index = index.sibling(index.row(), self.GLOBALS_COL_UNITS)
name_item = self.globals_model.itemFromIndex(name_index)
units_item = self.globals_model.itemFromIndex(units_index)
global_name = name_item.text()
logger.debug('%s:%s - check for boolean values: %s' %
(self.globals_file, self.group_name, global_name))
if value == 'True':
units_item.setData(True, self.GLOBALS_ROLE_IS_BOOL)
units_item.setText('Bool')
units_item.setData('!1', self.GLOBALS_ROLE_SORT_DATA)
units_item.setEditable(False)
units_item.setCheckState(QtCore.Qt.Checked)
units_item.setBackground(QtGui.QBrush(QtGui.QColor(self.COLOR_BOOL_ON)))
elif value == 'False':
units_item.setData(True, self.GLOBALS_ROLE_IS_BOOL)
units_item.setText('Bool')
units_item.setData('!0', self.GLOBALS_ROLE_SORT_DATA)
units_item.setEditable(False)
units_item.setCheckState(QtCore.Qt.Unchecked)
units_item.setBackground(QtGui.QBrush(QtGui.QColor(self.COLOR_BOOL_OFF)))
else:
was_bool = units_item.data(self.GLOBALS_ROLE_IS_BOOL)
units_item.setData(False, self.GLOBALS_ROLE_IS_BOOL)
units_item.setEditable(True)
# Checkbox still visible unless we do the following:
units_item.setData(None, QtCore.Qt.CheckStateRole)
units_item.setData(None, QtCore.Qt.BackgroundRole)
if was_bool:
# If the item was a bool and now isn't, clear the
# units and go into editing so the user can enter a
# new units string:
units_item.setText('')
self.ui.tableView_globals.setCurrentIndex(units_item.index())
self.ui.tableView_globals.edit(units_item.index())
[docs]
def globals_changed(self):
"""Called whenever something about a global has changed. call
app.globals_changed to inform the main application that it needs to
parse globals again. self.update_parse_indication will be called by
the main app when parsing is done, and will set the colours and
tooltips appropriately"""
# Tell the main app about it:
app.globals_changed()
[docs]
def delete_global(self, global_name, confirm=True):
logger.info('%s:%s - delete global: %s' %
(self.globals_file, self.group_name, global_name))
if confirm:
if not question_dialog("Delete the global '%s'?" % global_name):
return
runmanager.delete_global(self.globals_file, self.group_name, global_name)
# Find the entry for this global in self.globals_model and remove it:
name_item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_NAME)
self.globals_model.removeRow(name_item.row())
self.globals_changed()
[docs]
def update_parse_indication(self, active_groups, sequence_globals, evaled_globals):
# Check that we are an active group:
if self.group_name in active_groups and active_groups[self.group_name] == self.globals_file:
self.tab_contains_errors = False
# for global_name, value in evaled_globals[self.group_name].items():
for i in range(self.globals_model.rowCount()):
name_item = self.globals_model.item(i, self.GLOBALS_COL_NAME)
if name_item.data(self.GLOBALS_ROLE_IS_DUMMY_ROW):
continue
value_item = self.globals_model.item(i, self.GLOBALS_COL_VALUE)
expansion_item = self.globals_model.item(i, self.GLOBALS_COL_EXPANSION)
# value_item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_VALUE)
# expansion_item = self.get_global_item_by_name(global_name, self.GLOBALS_COL_EXPANSION)
global_name = name_item.text()
value = evaled_globals[self.group_name][global_name]
ignore, ignore, expansion = sequence_globals[self.group_name][global_name]
# Temporarily disconnect the item_changed signal on the model
# so that we can set the expansion type without triggering
# another preparse - the parsing has already been done with
# the new expansion type.
with self.globals_model_item_changed_disconnected:
if expansion_item.data(self.GLOBALS_ROLE_PREVIOUS_TEXT) != expansion:
# logger.info('expansion previous text set')
expansion_item.setData(expansion, self.GLOBALS_ROLE_PREVIOUS_TEXT)
if expansion_item.data(self.GLOBALS_ROLE_SORT_DATA) != expansion:
# logger.info('sort data role set')
expansion_item.setData(expansion, self.GLOBALS_ROLE_SORT_DATA)
# The next line will now trigger item_changed, but it will not
# be detected as an actual change to the expansion type,
# because previous_text will match text. So it will not look
# like a change and will not trigger preparsing. However It is
# still important that other triggers be processed, such as
# setting the icon in the expansion item, so that will still
# occur in the callback.
expansion_item.setText(expansion)
if isinstance(value, Exception):
value_item.setBackground(QtGui.QBrush(QtGui.QColor(self.COLOR_ERROR)))
value_item.setIcon(QtGui.QIcon(':qtutils/fugue/exclamation'))
tooltip = '%s: %s' % (value.__class__.__name__, str(value))
self.tab_contains_errors = True
else:
if value_item.background().color().name().lower() != self.COLOR_OK.lower():
value_item.setBackground(QtGui.QBrush(QtGui.QColor(self.COLOR_OK)))
if not value_item.icon().isNull():
# logger.info('clearing icon')
value_item.setData(None, QtCore.Qt.DecorationRole)
tooltip = repr(value)
if value_item.toolTip() != tooltip:
# logger.info('tooltip_changed')
value_item.setToolTip(tooltip)
if self.tab_contains_errors:
self.set_tab_icon(':qtutils/fugue/exclamation')
else:
self.set_tab_icon(None)
else:
# Clear everything:
self.set_tab_icon(None)
for row in range(self.globals_model.rowCount()):
item = self.globals_model.item(row, self.GLOBALS_COL_VALUE)
if item.data(self.GLOBALS_ROLE_IS_DUMMY_ROW):
continue
item.setData(None, QtCore.Qt.DecorationRole)
item.setToolTip('Group inactive')
item.setData(None, QtCore.Qt.BackgroundRole)
[docs]
class RunmanagerMainWindow(QtWidgets.QMainWindow):
# A signal to show that the window is shown and painted.
firstPaint = Signal()
[docs]
def __init__(self, *args, **kwargs):
QtWidgets.QMainWindow.__init__(self, *args, **kwargs)
self._previously_painted = False
[docs]
def closeEvent(self, event):
if app.on_close_event():
return QtWidgets.QMainWindow.closeEvent(self, event)
else:
event.ignore()
[docs]
def paintEvent(self, event):
result = QtWidgets.QMainWindow.paintEvent(self, event)
if not self._previously_painted:
self._previously_painted = True
self.firstPaint.emit()
return result
[docs]
class PoppedOutOutputBoxWindow(QtWidgets.QDialog):
[docs]
def closeEvent(self, event):
app.on_output_popout_button_clicked()
[docs]
class RunManager(object):
# Constants for the model in the axes tab:
AXES_COL_NAME = 0
AXES_COL_LENGTH = 1
AXES_COL_SHUFFLE = 2
AXES_ROLE_NAME = QtCore.Qt.UserRole + 1
# Constants for the model in the groups tab:
GROUPS_COL_NAME = 0
GROUPS_COL_ACTIVE = 1
GROUPS_COL_DELETE = 2
GROUPS_COL_OPENCLOSE = 3
GROUPS_ROLE_IS_DUMMY_ROW = QtCore.Qt.UserRole + 1
GROUPS_ROLE_PREVIOUS_NAME = QtCore.Qt.UserRole + 2
GROUPS_ROLE_SORT_DATA = QtCore.Qt.UserRole + 3
GROUPS_ROLE_GROUP_IS_OPEN = QtCore.Qt.UserRole + 4
GROUPS_DUMMY_ROW_TEXT = '<Click to add group>'
[docs]
def __init__(self):
splash.update_text('loading graphical interface')
loader = UiLoader()
loader.registerCustomWidget(FingerTabWidget)
loader.registerCustomWidget(TreeView)
self.ui = loader.load(
os.path.join(runmanager_dir, 'main.ui'), RunmanagerMainWindow()
)
self.output_box = OutputBox(self.ui.verticalLayout_output_tab)
# Add a 'pop-out' button to the output tab:
output_tab_index = self.ui.tabWidget.indexOf(self.ui.tab_output)
self.output_popout_button = TabToolButton(self.ui.tabWidget.parent())
self.output_popout_button.setIcon(QtGui.QIcon(':/qtutils/fugue/arrow-out'))
self.output_popout_button.setToolTip('Toggle whether the output box is in a separate window')
self.ui.tabWidget.tabBar().setTabButton(output_tab_index, QtWidgets.QTabBar.RightSide, self.output_popout_button)
# Fix the first three tabs in place:
for index in range(3):
self.ui.tabWidget.tabBar().setMovable(False, index=index)
# Whether or not the output box is currently popped out:
self.output_box_is_popped_out = False
# The window it will be moved to when popped out:
self.output_box_window = PoppedOutOutputBoxWindow(self.ui, QtCore.Qt.WindowSystemMenuHint)
self.output_box_window_verticalLayout = QtWidgets.QVBoxLayout(self.output_box_window)
self.output_box_window_verticalLayout.setContentsMargins(0, 0, 0, 0)
self.output_box_window.setWindowTitle('runmanager output')
self.output_box_window.resize(800, 1000)
self.setup_config()
self.setup_axes_tab()
self.setup_groups_tab()
self.connect_signals()
# The last location from which a labscript file was selected, defaults
# to labscriptlib:
self.last_opened_labscript_folder = self.exp_config.get('paths', 'labscriptlib')
# The last location from which a globals file was selected, defaults
# to experiment_shot_storage:
self.last_opened_globals_folder = self.exp_config.get('paths', 'experiment_shot_storage')
# The last file to which the user saved or loaded a configuration:
self.last_save_config_file = None
# The last manually selected shot output folder, defaults to
# experiment_shot_storage:
self.last_selected_shot_output_folder = self.exp_config.get('paths', 'experiment_shot_storage')
self.shared_drive_prefix = self.exp_config.get('paths', 'shared_drive')
self.experiment_shot_storage = self.exp_config.get('paths', 'experiment_shot_storage')
# Store the currently open groups as {(globals_filename, group_name): GroupTab}
self.currently_open_groups = {}
# A thread that will evaluate globals when they change, allowing us to
# show their values and any errors in the tabs they came from.
self.preparse_globals_thread = threading.Thread(target=self.preparse_globals_loop)
self.preparse_globals_thread.daemon = True
# A Queue for informing the preparser thread when globals have changed, and thus
# need parsing again. It is a queue rather than a threading.Event() so that
# callers can call Queue.join() to wait for parsing to complete in a race-free
# way
self.preparse_globals_required = queue.Queue()
self.preparse_globals_thread.start()
# A flag telling the compilation thread to abort:
self.compilation_aborted = threading.Event()
# A few attributes for self.guess_expansion_modes() to keep track of
# its state, and thus detect changes:
self.previous_evaled_globals = {}
self.previous_global_hierarchy = {}
self.previous_expansion_types = {}
self.previous_expansions = {}
# The prospective number of shots resulting from compilation
self.n_shots = None
# Start the loop that allows compilations to be queued up:
self.compile_queue = queue.Queue()
self.compile_queue_thread = threading.Thread(target=self.compile_loop)
self.compile_queue_thread.daemon = True
self.compile_queue_thread.start()
splash.update_text('starting compiler subprocess')
# Start the compiler subprocess:
self.to_child, self.from_child, self.child = process_tree.subprocess(
os.path.join(runmanager_dir, 'batch_compiler.py'),
output_redirection_port=self.output_box.port,
)
# Is blank until a labscript file is selected:
self.previous_default_output_folder = ''
# Start a thread to monitor the time of day and create new shot output
# folders for each day:
inthread(self.rollover_shot_output_folder)
self.non_default_folder = None
# The data from the last time we saved the configuration, so we can
# know if something's changed:
self.last_save_data = None
# autoload a config file, if labconfig is set to do so:
try:
autoload_config_file = self.exp_config.get('runmanager', 'autoload_config_file')
except (LabConfig.NoOptionError, LabConfig.NoSectionError):
self.output_box.output('Ready.\n\n')
else:
self.ui.setEnabled(False)
self.output_box.output('Loading default config file %s...' % autoload_config_file)
def load_the_config_file():
try:
self.load_configuration(autoload_config_file)
self.output_box.output('done.\n')
except Exception as e:
self.output_box.output('\nCould not load config file: %s: %s\n\n' %
(e.__class__.__name__, str(e)), red=True)
else:
self.output_box.output('Ready.\n\n')
finally:
self.ui.setEnabled(True)
# Defer this until 50ms after the window has shown,
# so that the GUI pops up faster in the meantime
self.ui.firstPaint.connect(lambda: QtCore.QTimer.singleShot(50, load_the_config_file))
splash.update_text('done')
self.ui.show()
[docs]
def setup_config(self):
required_config_params = {"DEFAULT": ["apparatus_name"],
"programs": ["text_editor",
"text_editor_arguments",
],
"ports": ['BLACS', 'runviewer'],
"paths": ["shared_drive",
"experiment_shot_storage",
"labscriptlib",
],
}
self.exp_config = LabConfig(required_params = required_config_params)
[docs]
def setup_axes_tab(self):
self.axes_model = QtGui.QStandardItemModel()
# Setup the model columns and link to the treeview
name_header_item = QtGui.QStandardItem('Name')
name_header_item.setToolTip('The name of the global or zip group being iterated over')
self.axes_model.setHorizontalHeaderItem(self.AXES_COL_NAME, name_header_item)
length_header_item = QtGui.QStandardItem('Length')
length_header_item.setToolTip('The number of elements in the axis of the parameter space')
self.axes_model.setHorizontalHeaderItem(self.AXES_COL_LENGTH, length_header_item)
shuffle_header_item = QtGui.QStandardItem('Shuffle')
shuffle_header_item.setToolTip('Whether or not the order of the axis should be randomised')
shuffle_header_item.setIcon(QtGui.QIcon(':qtutils/fugue/arrow-switch'))
self.axes_model.setHorizontalHeaderItem(self.AXES_COL_SHUFFLE, shuffle_header_item)
self.ui.treeView_axes.setModel(self.axes_model)
# Setup stuff for a custom context menu:
self.ui.treeView_axes.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
# Make the actions for the context menu:
self.action_axes_check_selected = QtWidgets.QAction(QtGui.QIcon(':qtutils/fugue/ui-check-box'),
'Check selected', self.ui)
self.action_axes_uncheck_selected = QtWidgets.QAction(QtGui.QIcon(':qtutils/fugue/ui-check-box-uncheck'),
'Uncheck selected', self.ui)
# setup header widths
self.ui.treeView_axes.header().setStretchLastSection(False)
self.ui.treeView_axes.header().setSectionResizeMode(self.AXES_COL_NAME, QtWidgets.QHeaderView.Stretch)
[docs]
def setup_groups_tab(self):
self.groups_model = QtGui.QStandardItemModel()
self.groups_model.setHorizontalHeaderLabels(['File/group name', 'Active', 'Delete', 'Open/Close'])
self.groups_model.setSortRole(self.GROUPS_ROLE_SORT_DATA)
self.ui.treeView_groups.setModel(self.groups_model)
self.ui.treeView_groups.setAnimated(True) # Pretty
self.ui.treeView_groups.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection)
self.ui.treeView_groups.setSortingEnabled(True)
self.ui.treeView_groups.sortByColumn(self.GROUPS_COL_NAME, QtCore.Qt.AscendingOrder)
# Set column widths:
self.ui.treeView_groups.setColumnWidth(self.GROUPS_COL_NAME, 400)
# Make it so the user can just start typing on an item to edit:
self.ui.treeView_groups.setEditTriggers(QtWidgets.QTreeView.AnyKeyPressed |
QtWidgets.QTreeView.EditKeyPressed |
QtWidgets.QTreeView.SelectedClicked)
# Ensure the clickable region of the open/close button doesn't extend forever:
self.ui.treeView_groups.header().setStretchLastSection(False)
# Stretch the filpath/groupname column to fill available space:
self.ui.treeView_groups.header().setSectionResizeMode(
self.GROUPS_COL_NAME, QtWidgets.QHeaderView.Stretch
)
# Shrink columns other than the 'name' column to the size of their headers:
for column in range(self.groups_model.columnCount()):
if column != self.GROUPS_COL_NAME:
self.ui.treeView_groups.resizeColumnToContents(column)
self.ui.treeView_groups.setTextElideMode(QtCore.Qt.ElideMiddle)
# Setup stuff for a custom context menu:
self.ui.treeView_groups.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
# Make the actions for the context menu:
self.action_groups_set_selection_active = QtWidgets.QAction(
QtGui.QIcon(':qtutils/fugue/ui-check-box'), 'Set selected group(s) active', self.ui)
self.action_groups_set_selection_inactive = QtWidgets.QAction(
QtGui.QIcon(':qtutils/fugue/ui-check-box-uncheck'), 'Set selected group(s) inactive', self.ui)
self.action_groups_delete_selected = QtWidgets.QAction(
QtGui.QIcon(':qtutils/fugue/minus'), 'Delete selected group(s)', self.ui)
self.action_groups_open_selected = QtWidgets.QAction(
QtGui.QIcon(':/qtutils/fugue/plus'), 'Open selected group(s)', self.ui)
self.action_groups_close_selected_groups = QtWidgets.QAction(
QtGui.QIcon(':/qtutils/fugue/cross'), 'Close selected group(s)', self.ui)
self.action_groups_close_selected_files = QtWidgets.QAction(
QtGui.QIcon(':/qtutils/fugue/cross'), 'Close selected file(s)', self.ui)
# A counter for keeping track of the recursion depth of
# self._groups_model_active_changed(). This is used so that some
# actions can be taken in response to initial data changes, but not to
# flow-on changes made by the method itself:
self.on_groups_model_active_changed_recursion_depth = 0
[docs]
def connect_signals(self):
# The button that pops the output box in and out:
self.output_popout_button.clicked.connect(self.on_output_popout_button_clicked)
# The menu items:
self.ui.actionLoad_configuration.triggered.connect(self.on_load_configuration_triggered)
self.ui.actionRevert_configuration.triggered.connect(self.on_revert_configuration_triggered)
self.ui.actionSave_configuration.triggered.connect(self.on_save_configuration_triggered)
self.ui.actionSave_configuration_as.triggered.connect(self.on_save_configuration_as_triggered)
self.ui.actionQuit.triggered.connect(self.ui.close)
# labscript file and folder selection stuff:
self.ui.toolButton_select_labscript_file.clicked.connect(self.on_select_labscript_file_clicked)
self.ui.toolButton_select_shot_output_folder.clicked.connect(self.on_select_shot_output_folder_clicked)
self.ui.toolButton_edit_labscript_file.clicked.connect(self.on_edit_labscript_file_clicked)
self.ui.toolButton_reset_shot_output_folder.clicked.connect(self.on_reset_shot_output_folder_clicked)
self.ui.lineEdit_labscript_file.textChanged.connect(self.on_labscript_file_text_changed)
self.ui.lineEdit_shot_output_folder.textChanged.connect(self.on_shot_output_folder_text_changed)
# Control buttons; engage, abort, restart subprocess:
self.ui.pushButton_engage.clicked.connect(self.on_engage_clicked)
self.ui.pushButton_abort.clicked.connect(self.on_abort_clicked)
self.ui.pushButton_restart_subprocess.clicked.connect(self.on_restart_subprocess_clicked)
# shuffle master control
self.ui.pushButton_shuffle.stateChanged.connect(self.on_master_shuffle_clicked)
# Tab closebutton clicked:
self.ui.tabWidget.tabCloseRequested.connect(self.on_tabCloseRequested)
# Axes tab; right click menu, menu actions, reordering
# self.ui.treeView_axes.customContextMenuRequested.connect(self.on_treeView_axes_context_menu_requested)
self.action_axes_check_selected.triggered.connect(self.on_axes_check_selected_triggered)
self.action_axes_uncheck_selected.triggered.connect(self.on_axes_uncheck_selected_triggered)
self.ui.toolButton_axis_to_top.clicked.connect(self.on_axis_to_top_clicked)
self.ui.toolButton_axis_up.clicked.connect(self.on_axis_up_clicked)
self.ui.toolButton_axis_down.clicked.connect(self.on_axis_down_clicked)
self.ui.toolButton_axis_to_bottom.clicked.connect(self.on_axis_to_bottom_clicked)
# axes tab item changed handler
self.axes_model.itemChanged.connect(self.on_axes_item_changed)
self.axes_model.rowsRemoved.connect(self.update_global_shuffle_state)
self.axes_model.rowsInserted.connect(self.update_global_shuffle_state)
# Groups tab; right click menu, menu actions, open globals file, new globals file, diff globals file,
self.ui.treeView_groups.customContextMenuRequested.connect(self.on_treeView_groups_context_menu_requested)
self.action_groups_set_selection_active.triggered.connect(
lambda: self.on_groups_set_selection_active_triggered(QtCore.Qt.Checked))
self.action_groups_set_selection_inactive.triggered.connect(
lambda: self.on_groups_set_selection_active_triggered(QtCore.Qt.Unchecked))
self.action_groups_delete_selected.triggered.connect(self.on_groups_delete_selected_triggered)
self.action_groups_open_selected.triggered.connect(self.on_groups_open_selected_triggered)
self.action_groups_close_selected_groups.triggered.connect(self.on_groups_close_selected_groups_triggered)
self.action_groups_close_selected_files.triggered.connect(self.on_groups_close_selected_files_triggered)
self.ui.pushButton_open_globals_file.clicked.connect(self.on_open_globals_file_clicked)
self.ui.pushButton_new_globals_file.clicked.connect(self.on_new_globals_file_clicked)
self.ui.pushButton_diff_globals_file.clicked.connect(self.on_diff_globals_file_clicked)
self.ui.treeView_groups.leftClicked.connect(self.on_treeView_groups_leftClicked)
self.ui.treeView_groups.doubleLeftClicked.connect(self.on_treeView_groups_doubleLeftClicked)
self.groups_model.itemChanged.connect(self.on_groups_model_item_changed)
# A context manager with which we can temporarily disconnect the above connection.
self.groups_model_item_changed_disconnected = DisconnectContextManager(
self.groups_model.itemChanged, self.on_groups_model_item_changed)
# Keyboard shortcuts:
engage_shortcut = QtWidgets.QShortcut('F5', self.ui,
lambda: self.ui.pushButton_engage.clicked.emit(False))
engage_shortcut.setAutoRepeat(False)
QtWidgets.QShortcut('ctrl+W', self.ui, self.close_current_tab)
QtWidgets.QShortcut('ctrl+Tab', self.ui, lambda: self.switch_tabs(+1))
QtWidgets.QShortcut('ctrl+shift+Tab', self.ui, lambda: self.switch_tabs(-1))
[docs]
def on_close_event(self):
save_data = self.get_save_data()
if self.last_save_data is not None and save_data != self.last_save_data:
message = ('Current configuration (which groups are active/open and other GUI state) '
'has changed: save config file \'%s\'?' % self.last_save_config_file)
reply = QtWidgets.QMessageBox.question(self.ui, 'Quit runmanager', message,
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Cancel)
if reply == QtWidgets.QMessageBox.Cancel:
return False
if reply == QtWidgets.QMessageBox.Yes:
self.save_configuration(self.last_save_config_file)
self.to_child.put(['quit', None])
return True
[docs]
def close_current_tab(self):
current_tab_widget = self.ui.tabWidget.currentWidget()
for (globals_file, group_name), tab in self.currently_open_groups.items():
if tab.ui is current_tab_widget:
self.close_group(globals_file, group_name)
[docs]
def switch_tabs(self, change):
current_index = self.ui.tabWidget.currentIndex()
n_tabs = self.ui.tabWidget.count()
new_index = (current_index + change) % n_tabs
self.ui.tabWidget.setCurrentIndex(new_index)
[docs]
def on_select_labscript_file_clicked(self, checked):
labscript_file = QtWidgets.QFileDialog.getOpenFileName(self.ui,
'Select labscript file',
self.last_opened_labscript_folder,
"Python files (*.py)")
if type(labscript_file) is tuple:
labscript_file, _ = labscript_file
if not labscript_file:
# User cancelled selection
return
# Convert to standard platform specific path, otherwise Qt likes forward slashes:
labscript_file = os.path.abspath(labscript_file)
if not os.path.isfile(labscript_file):
error_dialog("No such file %s." % labscript_file)
return
# Save the containing folder for use next time we open the dialog box:
self.last_opened_labscript_folder = os.path.dirname(labscript_file)
# Write the file to the lineEdit:
self.ui.lineEdit_labscript_file.setText(labscript_file)
[docs]
def on_edit_labscript_file_clicked(self, checked):
# get path to text editor
editor_path = self.exp_config.get('programs', 'text_editor')
editor_args = self.exp_config.get('programs', 'text_editor_arguments')
# Get the current labscript file:
current_labscript_file = self.ui.lineEdit_labscript_file.text()
# Ignore if no file selected
if not current_labscript_file:
return
if not editor_path:
error_dialog("No editor specified in the labconfig.")
if '{file}' in editor_args:
# Split the args on spaces into a list, replacing {file} with the labscript file
editor_args = [arg if arg != '{file}' else current_labscript_file for arg in editor_args.split()]
else:
# Otherwise if {file} isn't already in there, append it to the other args:
editor_args = [current_labscript_file] + editor_args.split()
try:
subprocess.Popen([editor_path] + editor_args)
except Exception as e:
error_dialog("Unable to launch text editor specified in %s. Error was: %s" %
(self.exp_config.config_path, str(e)))
[docs]
def on_select_shot_output_folder_clicked(self, checked):
shot_output_folder = QtWidgets.QFileDialog.getExistingDirectory(self.ui,
'Select shot output folder',
self.last_selected_shot_output_folder)
if type(shot_output_folder) is tuple:
shot_output_folder, _ = shot_output_folder
if not shot_output_folder:
# User cancelled selection
return
# Convert to standard platform specific path, otherwise Qt likes forward slashes:
shot_output_folder = os.path.abspath(shot_output_folder)
# Save the containing folder for use next time we open the dialog box:
self.last_selected_shot_output_folder = os.path.dirname(shot_output_folder)
# Write the file to the lineEdit:
self.ui.lineEdit_shot_output_folder.setText(shot_output_folder)
# Update our knowledge about whether this is the default output folder or not:
self.check_output_folder_update()
[docs]
def on_reset_shot_output_folder_clicked(self, checked):
current_default_output_folder = self.get_default_output_folder()
if current_default_output_folder is None:
return
self.ui.lineEdit_shot_output_folder.setText(current_default_output_folder)
self.check_output_folder_update()
[docs]
def on_labscript_file_text_changed(self, text):
# Blank out the 'edit labscript file' button if no labscript file is
# selected
enabled = bool(text)
self.ui.toolButton_edit_labscript_file.setEnabled(enabled)
# Blank out the 'select shot output folder' button if no labscript
# file is selected:
self.ui.toolButton_select_shot_output_folder.setEnabled(enabled)
self.ui.lineEdit_labscript_file.setToolTip(text)
# Check if the output folder needs to be updated:
self.check_output_folder_update()
[docs]
def on_shot_output_folder_text_changed(self, text):
# Blank out the 'reset default output folder' button if the user is
# already using the default output folder
if text == self.get_default_output_folder():
self.non_default_folder = False
else:
self.non_default_folder = True
self.ui.toolButton_reset_shot_output_folder.setEnabled(self.non_default_folder)
self.ui.label_non_default_folder.setVisible(self.non_default_folder)
self.ui.lineEdit_shot_output_folder.setToolTip(text)
[docs]
def on_engage_clicked(self):
logger.info('Engage')
try:
send_to_BLACS = self.ui.checkBox_run_shots.isChecked()
send_to_runviewer = self.ui.checkBox_view_shots.isChecked()
labscript_file = self.ui.lineEdit_labscript_file.text()
# even though we shuffle on a per global basis, if ALL of the globals are set to shuffle, then we may as well shuffle again. This helps shuffle shots more randomly than just shuffling within each level (because without this, you would still do all shots with the outer most variable the same, etc)
shuffle = self.ui.pushButton_shuffle.checkState() == QtCore.Qt.Checked
if not labscript_file:
raise Exception('Error: No labscript file selected')
output_folder = self.ui.lineEdit_shot_output_folder.text()
if not output_folder:
raise Exception('Error: No output folder selected')
BLACS_host = self.ui.lineEdit_BLACS_hostname.text()
logger.info('Parsing globals...')
active_groups = self.get_active_groups()
# Get ordering of expansion globals
expansion_order = {}
for i in range(self.axes_model.rowCount()):
item = self.axes_model.item(i, self.AXES_COL_NAME)
shuffle_item = self.axes_model.item(i, self.AXES_COL_SHUFFLE)
name = item.data(self.AXES_ROLE_NAME)
expansion_order[name] = {'order':i, 'shuffle':shuffle_item.checkState()}
try:
sequenceglobals, shots, evaled_globals, global_hierarchy, expansions = self.parse_globals(active_groups, expansion_order=expansion_order)
except Exception as e:
raise Exception('Error parsing globals:\n%s\nCompilation aborted.' % str(e))
logger.info('Making h5 files')
labscript_file, run_files = self.make_h5_files(
labscript_file, output_folder, sequenceglobals, shots, shuffle)
self.ui.pushButton_abort.setEnabled(True)
self.compile_queue.put([labscript_file, run_files, send_to_BLACS, BLACS_host, send_to_runviewer])
except Exception as e:
self.output_box.output('%s\n\n' % str(e), red=True)
logger.info('end engage')
[docs]
def on_abort_clicked(self):
self.compilation_aborted.set()
[docs]
def on_restart_subprocess_clicked(self):
# Kill and restart the compilation subprocess
self.to_child.put(['quit', None])
self.from_child.put(['done', False])
time.sleep(0.1)
self.output_box.output('Asking subprocess to quit...')
timeout_time = time.time() + 2
QtCore.QTimer.singleShot(50, lambda: self.check_child_exited(timeout_time, kill=False))
[docs]
def check_child_exited(self, timeout_time, kill=False):
self.child.poll()
if self.child.returncode is None and time.time() < timeout_time:
QtCore.QTimer.singleShot(50, lambda: self.check_child_exited(timeout_time, kill))
return
elif self.child.returncode is None:
if not kill:
self.child.terminate()
self.output_box.output('not responding.\n')
timeout_time = time.time() + 2
QtCore.QTimer.singleShot(50, lambda: self.check_child_exited(timeout_time, kill=True))
return
else:
self.child.kill()
self.output_box.output('Killed\n', red=True)
elif kill:
self.output_box.output('Terminated\n', red=True)
else:
self.output_box.output('done.\n')
self.output_box.output('Spawning new compiler subprocess...')
self.to_child, self.from_child, self.child = process_tree.subprocess(
os.path.join(runmanager_dir, 'batch_compiler.py'),
output_redirection_port=self.output_box.port,
)
self.output_box.output('done.\n')
self.output_box.output('Ready.\n\n')
[docs]
def on_tabCloseRequested(self, index):
tab_page = self.ui.tabWidget.widget(index)
for (globals_file, group_name), group_tab in self.currently_open_groups.items():
if group_tab.ui is tab_page:
self.close_group(globals_file, group_name)
break
[docs]
def on_axes_check_selected_triggered(self, *args):
raise NotImplementedError
[docs]
def on_axes_uncheck_selected_triggered(self, *args):
raise NotImplementedError
[docs]
def on_axis_to_top_clicked(self, checked):
# Get the selection model from the treeview
selection_model = self.ui.treeView_axes.selectionModel()
# Create a list of select row indices
selected_row_list = [index.row() for index in sorted(selection_model.selectedRows())]
# For each row selected
for i,row in enumerate(selected_row_list):
# only move the row while it is not element 0, and the row above it is not selected
# (note that while a row above may have been initially selected, it should by now, be one row higher
# since we start moving elements of the list upwards starting from the lowest index)
while row > 0 and (row-1) not in selected_row_list:
# Remove the selected row
items = self.axes_model.takeRow(row)
# Add the selected row into a position one above
self.axes_model.insertRow(row-1,items)
# Since it is now a newly inserted row, select it again
selection_model.select(self.axes_model.indexFromItem(items[0]),QtCore.QItemSelectionModel.SelectCurrent|QtCore.QItemSelectionModel.Rows)
# reupdate the list of selected indices to reflect this change
selected_row_list[i] -= 1
row -= 1
self.update_axes_indentation()
[docs]
def on_axis_up_clicked(self, checked):
# Get the selection model from the treeview
selection_model = self.ui.treeView_axes.selectionModel()
# Create a list of select row indices
selected_row_list = [index.row() for index in sorted(selection_model.selectedRows())]
# For each row selected
for i,row in enumerate(selected_row_list):
# only move the row if it is not element 0, and the row above it is not selected
# (note that while a row above may have been initially selected, it should by now, be one row higher
# since we start moving elements of the list upwards starting from the lowest index)
if row > 0 and (row-1) not in selected_row_list:
# Remove the selected row
items = self.axes_model.takeRow(row)
# Add the selected row into a position one above
self.axes_model.insertRow(row-1,items)
# Since it is now a newly inserted row, select it again
selection_model.select(self.axes_model.indexFromItem(items[0]),QtCore.QItemSelectionModel.SelectCurrent|QtCore.QItemSelectionModel.Rows)
# reupdate the list of selected indices to reflect this change
selected_row_list[i] -= 1
self.update_axes_indentation()
[docs]
def on_axis_down_clicked(self, checked):
# Get the selection model from the treeview
selection_model = self.ui.treeView_axes.selectionModel()
# Create a list of select row indices
selected_row_list = [index.row() for index in reversed(sorted(selection_model.selectedRows()))]
# For each row selected
for i,row in enumerate(selected_row_list):
# only move the row if it is not the last element, and the row above it is not selected
# (note that while a row below may have been initially selected, it should by now, be one row lower
# since we start moving elements of the list upwards starting from the highest index)
if row < self.axes_model.rowCount()-1 and (row+1) not in selected_row_list:
# Remove the selected row
items = self.axes_model.takeRow(row)
# Add the selected row into a position one above
self.axes_model.insertRow(row+1,items)
# Since it is now a newly inserted row, select it again
selection_model.select(self.axes_model.indexFromItem(items[0]),QtCore.QItemSelectionModel.SelectCurrent|QtCore.QItemSelectionModel.Rows)
# reupdate the list of selected indices to reflect this change
selected_row_list[i] += 1
self.update_axes_indentation()
[docs]
def on_axis_to_bottom_clicked(self, checked):
selection_model = self.ui.treeView_axes.selectionModel()
# Create a list of select row indices
selected_row_list = [index.row() for index in reversed(sorted(selection_model.selectedRows()))]
# For each row selected
for i,row in enumerate(selected_row_list):
# only move the row while it is not the last element, and the row above it is not selected
# (note that while a row below may have been initially selected, it should by now, be one row lower
# since we start moving elements of the list upwards starting from the highest index)
while row < self.axes_model.rowCount()-1 and (row+1) not in selected_row_list:
# Remove the selected row
items = self.axes_model.takeRow(row)
# Add the selected row into a position one above
self.axes_model.insertRow(row+1,items)
# Since it is now a newly inserted row, select it again
selection_model.select(self.axes_model.indexFromItem(items[0]),QtCore.QItemSelectionModel.SelectCurrent|QtCore.QItemSelectionModel.Rows)
# reupdate the list of selected indices to reflect this change
selected_row_list[i] += 1
row += 1
self.update_axes_indentation()
[docs]
def on_axes_item_changed(self, item):
if item.column() == self.AXES_COL_SHUFFLE:
self.update_global_shuffle_state()
[docs]
def update_global_shuffle_state(self, *args, **kwargs):
all_checked = True
none_checked = True
for i in range(self.axes_model.rowCount()):
check_state = self.axes_model.item(i, self.AXES_COL_SHUFFLE).checkState() == QtCore.Qt.Checked
all_checked = all_checked and check_state
none_checked = none_checked and not check_state
if not all_checked and not none_checked:
self.ui.pushButton_shuffle.setTristate(True)
self.ui.pushButton_shuffle.setCheckState(QtCore.Qt.PartiallyChecked)
elif none_checked and not all_checked:
self.ui.pushButton_shuffle.setTristate(False)
self.ui.pushButton_shuffle.setCheckState(QtCore.Qt.Unchecked)
elif all_checked and not none_checked:
self.ui.pushButton_shuffle.setTristate(False)
self.ui.pushButton_shuffle.setCheckState(QtCore.Qt.Checked)
else:
# No axes. Set if partially checked, otherwise else leave it alone:
if self.ui.pushButton_shuffle.checkState() == QtCore.Qt.PartiallyChecked:
self.ui.pushButton_shuffle.setTristate(False)
self.ui.pushButton_shuffle.setCheckState(QtCore.Qt.Checked)
[docs]
def on_master_shuffle_clicked(self, state):
if state in [QtCore.Qt.Checked, QtCore.Qt.Unchecked]:
self.ui.pushButton_shuffle.setTristate(False)
for i in range(self.axes_model.rowCount()):
item = self.axes_model.item(i, self.AXES_COL_SHUFFLE)
if item.checkState() != state:
self.axes_model.item(i, self.AXES_COL_SHUFFLE).setCheckState(state)
[docs]
def on_groups_copy_selected_groups_triggered(self, dest_globals_file=None, delete_source_group=False):
selected_indexes = self.ui.treeView_groups.selectedIndexes()
selected_items = (self.groups_model.itemFromIndex(index) for index in selected_indexes)
name_items = [item for item in selected_items
if item.column() == self.GROUPS_COL_NAME
and item.parent() is not None]
for item in name_items:
source_globals_file = item.parent().text()
self.copy_group(source_globals_file, item.text(), dest_globals_file, delete_source_group)
[docs]
def on_groups_set_selection_active_triggered(self, checked_state):
selected_indexes = self.ui.treeView_groups.selectedIndexes()
# Filter to only include the 'active' column:
selected_items = (self.groups_model.itemFromIndex(index) for index in selected_indexes)
active_items = (item for item in selected_items
if item.column() == self.GROUPS_COL_ACTIVE
and item.parent() is not None)
for item in active_items:
item.setCheckState(checked_state)
[docs]
def on_groups_delete_selected_triggered(self):
selected_indexes = self.ui.treeView_groups.selectedIndexes()
selected_items = (self.groups_model.itemFromIndex(index) for index in selected_indexes)
name_items = [item for item in selected_items
if item.column() == self.GROUPS_COL_NAME
and item.parent() is not None]
# If multiple selected, show 'delete n groups?' message. Otherwise,
# pass confirm=True to self.delete_group so it can show the regular
# message.
confirm_multiple = (len(name_items) > 1)
if confirm_multiple:
if not question_dialog("Delete %d groups?" % len(name_items)):
return
for item in name_items:
globals_file = item.parent().text()
group_name = item.text()
self.delete_group(globals_file, group_name, confirm=not confirm_multiple)
[docs]
def on_groups_open_selected_triggered(self):
selected_indexes = self.ui.treeView_groups.selectedIndexes()
selected_items = [self.groups_model.itemFromIndex(index) for index in selected_indexes]
name_items = [item for item in selected_items
if item.column() == self.GROUPS_COL_NAME
and item.parent() is not None]
# Include all grous of selected globals files:
for item in selected_items:
if item.parent() is None:
children = [item.child(i) for i in range(item.rowCount())]
# Exclude <add new group> item, which is not selectable
name_items += [child for child in children if child.isSelectable() ]
filenames = set(item.parent().text() for item in name_items)
for item in name_items:
globals_file = item.parent().text()
group_name = item.text()
if (globals_file, group_name) not in self.currently_open_groups:
self.open_group(globals_file, group_name, trigger_preparse=False)
if name_items:
self.globals_changed()
[docs]
def on_groups_close_selected_groups_triggered(self):
selected_indexes = self.ui.treeView_groups.selectedIndexes()
selected_items = (self.groups_model.itemFromIndex(index) for index in selected_indexes)
name_items = [item for item in selected_items
if item.column() == self.GROUPS_COL_NAME
and item.parent() is not None]
for item in name_items:
globals_file = item.parent().text()
group_name = item.text()
if (globals_file, group_name) in self.currently_open_groups:
self.close_group(globals_file, group_name)
[docs]
def on_groups_close_selected_files_triggered(self):
selected_indexes = self.ui.treeView_groups.selectedIndexes()
selected_items = (self.groups_model.itemFromIndex(index) for index in selected_indexes)
name_items = [item for item in selected_items
if item.column() == self.GROUPS_COL_NAME
and item.parent() is None]
child_openclose_items = [item.child(i, self.GROUPS_COL_OPENCLOSE)
for item in name_items
for i in range(item.rowCount())]
child_is_open = [child_item.data(self.GROUPS_ROLE_GROUP_IS_OPEN)
for child_item in child_openclose_items]
if any(child_is_open):
if not question_dialog('Close %d file(s)? This will close %d currently open group(s).' %
(len(name_items), child_is_open.count(True))):
return
for item in name_items:
globals_file = item.text()
self.close_globals_file(globals_file, confirm=False)
[docs]
def on_open_globals_file_clicked(self):
globals_file = QtWidgets.QFileDialog.getOpenFileName(self.ui,
'Select globals file',
self.last_opened_globals_folder,
"HDF5 files (*.h5)")
if type(globals_file) is tuple:
globals_file, _ = globals_file
if not globals_file:
# User cancelled selection
return
# Convert to standard platform specific path, otherwise Qt likes forward slashes:
globals_file = os.path.abspath(globals_file)
if not os.path.isfile(globals_file):
error_dialog("No such file %s." % globals_file)
return
# Save the containing folder for use next time we open the dialog box:
self.last_opened_globals_folder = os.path.dirname(globals_file)
# Open the file:
self.open_globals_file(globals_file)
[docs]
def on_new_globals_file_clicked(self):
globals_file = QtWidgets.QFileDialog.getSaveFileName(self.ui,
'Create new globals file',
self.last_opened_globals_folder,
"HDF5 files (*.h5)")
if type(globals_file) is tuple:
globals_file, _ = globals_file
if not globals_file:
# User cancelled
return
# Convert to standard platform specific path, otherwise Qt likes
# forward slashes:
globals_file = os.path.abspath(globals_file)
# Save the containing folder for use next time we open the dialog box:
self.last_opened_globals_folder = os.path.dirname(globals_file)
# Create the new file and open it:
runmanager.new_globals_file(globals_file)
self.open_globals_file(globals_file)
[docs]
def on_diff_globals_file_clicked(self):
globals_file = QtWidgets.QFileDialog.getOpenFileName(self.ui,
'Select globals file to compare',
self.last_opened_globals_folder,
"HDF5 files (*.h5)")
if type(globals_file) is tuple:
globals_file, _ = globals_file
if not globals_file:
# User cancelled
return
# Convert to standard platform specific path, otherwise Qt likes forward slashes:
globals_file = os.path.abspath(globals_file)
# Get runmanager's globals
active_groups = self.get_active_groups()
if active_groups is None:
# Invalid group selection
return
# Get file's globals groups
other_groups = runmanager.get_all_groups(globals_file)
# Display the output tab so the user can see the output:
self.ui.tabWidget.setCurrentWidget(self.ui.tab_output)
self.output_box.output('Globals diff with:\n%s\n\n' % globals_file)
# Do the globals diff
globals_diff_table = runmanager.globals_diff_groups(active_groups, other_groups)
self.output_box.output(globals_diff_table)
self.output_box.output('Ready.\n\n')
[docs]
def on_treeView_groups_leftClicked(self, index):
"""Here we respond to user clicks on the treeview. We do the following:
- If the user clicks on the <click to add group> dummy row, we go into
edit mode on it so they can enter the name of the new group they
want.
- If the user clicks on the icon to open or close a globals file or a
group, we call the appropriate open and close methods and update the
open/close data role on the model.
- If the user clicks delete on a globals group, we call a delete
method, which deletes it after confirmation, and closes it if it was
open.
"""
if qapplication.keyboardModifiers() != QtCore.Qt.NoModifier:
# Only handle mouseclicks with no keyboard modifiers.
return
item = self.groups_model.itemFromIndex(index)
# The 'name' item in the same row:
name_index = index.sibling(index.row(), self.GROUPS_COL_NAME)
name_item = self.groups_model.itemFromIndex(name_index)
# The parent item, None if there is no parent:
parent_item = item.parent()
# What kind of row did the user click on?
# A globals file, a group, or a 'click to add group' row?
if item.data(self.GROUPS_ROLE_IS_DUMMY_ROW):
# They clicked on an 'add new group' row. Enter editing
# mode on the name item so they can enter a name for
# the new group:
self.ui.treeView_groups.setCurrentIndex(name_index)
self.ui.treeView_groups.edit(name_index)
if item.column() == self.GROUPS_COL_ACTIVE:
# They clicked on the active column. Toggle the checkbox. We do
# this manually because setting the item checkable means the model
# changes before we catch the mouse click. This is a pain because
# we want the ensuing sorting (if the user is sorting by the
# enabled column) to keep the the selection. If the user only
# selected the column by clicking on it, then the sort happens
# before they selected it, and the resort happens without a visual
# indication of where the item went, because it never got
# selected.
state = item.checkState()
if state in (QtCore.Qt.Unchecked, QtCore.Qt.PartiallyChecked):
item.setCheckState(QtCore.Qt.Checked)
elif state == QtCore.Qt.Checked:
item.setCheckState(QtCore.Qt.Unchecked)
else:
raise AssertionError('Invalid Check state')
# If this changed the sort order, ensure the item is still visible:
scroll_view_to_row_if_current(self.ui.treeView_groups, item)
elif parent_item is None:
# They clicked on a globals file row.
globals_file = name_item.text()
# What column did they click on?
if item.column() == self.GROUPS_COL_OPENCLOSE:
# They clicked the close button. Close the file:
self.close_globals_file(globals_file)
else:
# They clicked on a globals group row.
globals_file = parent_item.text()
group_name = name_item.text()
# What column did they click on?
if item.column() == self.GROUPS_COL_DELETE:
# They clicked the delete button. Delete the group:
self.delete_group(globals_file, group_name, confirm=True)
elif item.column() == self.GROUPS_COL_OPENCLOSE:
# They clicked the open/close button. Which is it, open or close?
group_is_open = item.data(self.GROUPS_ROLE_GROUP_IS_OPEN)
if group_is_open:
self.close_group(globals_file, group_name)
else:
self.open_group(globals_file, group_name)
[docs]
def on_treeView_groups_doubleLeftClicked(self, index):
item = self.groups_model.itemFromIndex(index)
# The parent item, None if there is no parent:
parent_item = item.parent()
if item.data(self.GROUPS_ROLE_IS_DUMMY_ROW):
return
elif parent_item and item.column() == self.GROUPS_COL_NAME:
# it's a group name item. What's the group and file name?
globals_file = parent_item.text()
group_name = item.text()
if (globals_file, group_name) not in self.currently_open_groups:
self.open_group(globals_file, group_name)
# Focus the tab:
group_tab = self.currently_open_groups[globals_file, group_name]
for i in range(self.ui.tabWidget.count()):
if self.ui.tabWidget.widget(i) is group_tab.ui:
self.ui.tabWidget.setCurrentIndex(i)
break
[docs]
def on_groups_model_item_changed(self, item):
"""This function is for responding to data changes in the model. The
methods for responding to changes different columns do different
things. Mostly they make other data changes for model consistency, but
also group creation and renaming is handled in response to changes to
the 'name' column. When we change things elsewhere, we prefer to only
change one thing, and the rest of the changes are triggered here. So
here we do the following:
Be careful not to recurse unsafely into this method - changing
something that itself triggers further changes is fine so long as they
peter out and don't get stuck in a loop. If recursion needs to be
stopped, one can disconnect the signal temporarily with the context
manager self.groups_model_item_changed_disconnected. But use this
sparingly, otherwise there's the risk that some required data updates
will be forgotten about and won't happen.
"""
if item.column() == self.GROUPS_COL_NAME:
self.on_groups_model_name_changed(item)
elif item.column() == self.GROUPS_COL_ACTIVE:
self.on_groups_model_active_changed(item)
elif item.column() == self.GROUPS_COL_OPENCLOSE:
self.on_groups_model_openclose_changed(item)
[docs]
def on_groups_model_name_changed(self, item):
"""Handles group renaming and creation of new groups due to the user
editing the <click to add group> item"""
parent_item = item.parent()
# File rows are supposed to be uneditable, but just to be sure we have
# a group row:
assert parent_item is not None
if item.data(self.GROUPS_ROLE_IS_DUMMY_ROW):
item_text = item.text()
if item_text != self.GROUPS_DUMMY_ROW_TEXT:
# The user has made a new globals group by editing the <click
# to add group> item.
globals_file = parent_item.text()
group_name = item_text
self.new_group(globals_file, group_name)
else:
# User has renamed a globals group.
new_group_name = item.text()
previous_group_name = item.data(self.GROUPS_ROLE_PREVIOUS_NAME)
# Ensure it truly is a name change, and not something else about
# the item changing:
if new_group_name != previous_group_name:
globals_file = parent_item.text()
self.rename_group(globals_file, previous_group_name, new_group_name)
[docs]
def on_groups_model_active_changed(self, item):
"""Sets the sort data for the item in response to its check state
changing. Also, if this is the first time this function has been
called on the stack, that is, the change was initiated externally
instead of via recursion from this function itself, then set the check
state of other items for consistency. This entails checking/unchecking
all group rows in response to the file row's check state changing, or
changing the file row's check state to reflect the check state of the
child group rows. That's why we need to keep track of the recursion
depth - so that those changes we make don't in turn cause further
changes. But we don't disconnect the on_changed signal altogether,
because we still want to do the update of the sort data, and anything
else that might be added in future."""
self.on_groups_model_active_changed_recursion_depth += 1
try:
check_state = item.checkState()
# Ensure sort data matches active state:
item.setData(check_state, self.GROUPS_ROLE_SORT_DATA)
if self.on_groups_model_active_changed_recursion_depth > 1:
# Prevent all below code from running in response to data changes
# initiated from within this method itself. The code above this
# check still runs in response to all changes.
return
parent_item = item.parent()
if parent_item is not None:
# A 'group active' checkbox changed due to external action (not from this method itself).
# Update the parent file checkbox to reflect the state of its children
children = [parent_item.child(i, self.GROUPS_COL_ACTIVE) for i in range(parent_item.rowCount())]
child_states = [child.checkState() for child in children
if not child.data(self.GROUPS_ROLE_IS_DUMMY_ROW)]
parent_active_index = parent_item.index().sibling(parent_item.index().row(), self.GROUPS_COL_ACTIVE)
parent_active_item = self.groups_model.itemFromIndex(parent_active_index)
if all(state == QtCore.Qt.Checked for state in child_states):
parent_active_item.setCheckState(QtCore.Qt.Checked)
elif all(state == QtCore.Qt.Unchecked for state in child_states):
parent_active_item.setCheckState(QtCore.Qt.Unchecked)
else:
parent_active_item.setCheckState(QtCore.Qt.PartiallyChecked)
else:
# A 'file active' checkbox changed due to external action (not from this method itself).
# Update the check state of all children to match.
name_index = item.index().sibling(item.index().row(), self.GROUPS_COL_NAME)
name_item = self.groups_model.itemFromIndex(name_index)
checkstate = item.checkState()
children = [name_item.child(i, self.GROUPS_COL_ACTIVE) for i in range(name_item.rowCount())]
for child in children:
if not child.data(self.GROUPS_ROLE_IS_DUMMY_ROW):
child.setCheckState(checkstate)
finally:
self.on_groups_model_active_changed_recursion_depth -= 1
if self.on_groups_model_active_changed_recursion_depth == 0:
self.do_model_sort()
# Trigger a preparse to occur:
self.globals_changed()
[docs]
def on_groups_model_openclose_changed(self, item):
"""Sets item sort data and icon in response to the open/close state of a group
changing."""
parent_item = item.parent()
# The open/close state of a globals group changed. It is definitely a
# group, not a file, as the open/close state of a file shouldn't be
# changing.
assert parent_item is not None # Just to be sure.
# Ensure the sort data matches the open/close state:
group_is_open = item.data(self.GROUPS_ROLE_GROUP_IS_OPEN)
item.setData(group_is_open, self.GROUPS_ROLE_SORT_DATA)
# Set the appropriate icon and tooltip. Changing the icon causes
# itemChanged to be emitted, even if it the same icon, and even if we
# were to use the same QIcon instance. So to avoid infinite recursion
# we temporarily disconnect the signal whilst we set the icons.
with self.groups_model_item_changed_disconnected:
if group_is_open:
item.setIcon(QtGui.QIcon(':qtutils/fugue/cross'))
item.setToolTip('Close globals group.')
else:
item.setIcon(QtGui.QIcon(':qtutils/fugue/plus'))
item.setToolTip('Load globals group into runmanager.')
self.do_model_sort()
# If this changed the sort order, ensure the item is still visible:
scroll_view_to_row_if_current(self.ui.treeView_groups, item)
[docs]
@inmain_decorator()
def get_default_output_folder(self):
"""Returns what the default output folder would be right now, based on
the current date and selected labscript file. Returns empty string if
no labscript file is selected. Does not create the default output
folder, does not check if it exists."""
current_labscript_file = self.ui.lineEdit_labscript_file.text()
if not current_labscript_file:
return ''
_, default_output_folder, _ = runmanager.new_sequence_details(
current_labscript_file,
config=self.exp_config,
increment_sequence_index=False,
)
default_output_folder = os.path.normpath(default_output_folder)
return default_output_folder
[docs]
def rollover_shot_output_folder(self):
"""Runs in a thread, checking every 30 seconds if the default output folder has
changed, likely because the date has changed, but also possible because another
instance of runmanager has incremented the sequence index. If the defaulr output
folder has changed, and if runmanager is configured to use the default output
folder, sets the folder in which compiled shots will be put. Does not create the
folder if it does not already exist, this will be done at compile-time."""
while True:
time.sleep(30)
try:
self.check_output_folder_update()
except Exception as e:
# Don't stop the thread.
logger.exception("error checking default output folder")
[docs]
@inmain_decorator()
def check_output_folder_update(self):
"""Do a single check of whether the output folder needs updating. This
is implemented as a separate function to the above loop so that the
whole check happens at once in the Qt main thread and hence is atomic
and can't be interfered with by other Qt calls in the program."""
current_default_output_folder = self.get_default_output_folder()
if current_default_output_folder is None:
# No labscript file selected:
return
currently_selected_output_folder = self.ui.lineEdit_shot_output_folder.text()
if current_default_output_folder != self.previous_default_output_folder:
# It's a new day, or a new labscript file.
# Is the user using default folders?
if currently_selected_output_folder == self.previous_default_output_folder:
# Yes they are. In that case, update to use the new folder:
self.ui.lineEdit_shot_output_folder.setText(current_default_output_folder)
self.previous_default_output_folder = current_default_output_folder
[docs]
@inmain_decorator()
def globals_changed(self):
"""Called from either self, a GroupTab, or the RemoteServer to inform runmanager
that something about globals has changed, and that they need parsing again."""
self.ui.pushButton_engage.setEnabled(False)
self.preparse_globals_required.put(None)
[docs]
def update_axes_indentation(self):
for i in range(self.axes_model.rowCount()):
item = self.axes_model.item(i, self.AXES_COL_NAME)
text = item.text().lstrip()
text = ' '*i + text
item.setText(text)
[docs]
@inmain_decorator() # Is called by preparser thread
def update_axes_tab(self, expansions, dimensions):
# get set of expansions
expansion_list = []
for global_name, expansion in expansions.items():
if expansion:
if expansion == 'outer':
expansion_list.append('outer '+global_name)
else:
expansion_list.append('zip '+expansion)
expansion_list = set(expansion_list)
# find items to delete
for i in reversed(range(self.axes_model.rowCount())):
item = self.axes_model.item(i, self.AXES_COL_NAME)
name = item.data(self.AXES_ROLE_NAME)
if name not in expansion_list:
item = self.axes_model.takeRow(i)
del item
else:
length_item = self.axes_model.item(i, self.AXES_COL_LENGTH)
if name in dimensions:
length_item.setText("{}".format(dimensions[name]))
else:
length_item.setText('Unknown')
# remove from expansions list so we don't add it again
expansion_list.remove(name)
# add new rows
for expansion_name in expansion_list:
shuffle = self.ui.pushButton_shuffle.checkState() != QtCore.Qt.Unchecked
self.add_item_to_axes_model(expansion_name, shuffle, dimensions)
self.update_axes_indentation()
[docs]
def add_item_to_axes_model(self, expansion_name, shuffle, dimensions = None):
if dimensions is None:
dimensions = {}
items = []
expansion_type, name = expansion_name.split()
name_item = QtGui.QStandardItem(name)
name_item.setData(expansion_name, self.AXES_ROLE_NAME)
if expansion_type == 'outer':
name_item.setIcon(QtGui.QIcon(':qtutils/custom/outer'))
else:
name_item.setIcon(QtGui.QIcon(':qtutils/custom/zip'))
items.append(name_item)
length = 'Unknown'
if expansion_name in dimensions:
length = "{}".format(dimensions[expansion_name])
length_item = QtGui.QStandardItem(length)
items.append(length_item)
shuffle_item = QtGui.QStandardItem()
shuffle_item.setCheckable(True)
shuffle_item.setCheckState(QtCore.Qt.Checked if shuffle else QtCore.Qt.Unchecked)
items.append(shuffle_item)
self.axes_model.appendRow(items)
[docs]
@inmain_decorator() # Is called by preparser thread
def update_tabs_parsing_indication(self, active_groups, sequence_globals, evaled_globals, n_shots):
for group_tab in self.currently_open_groups.values():
group_tab.update_parse_indication(active_groups, sequence_globals, evaled_globals)
self.ui.pushButton_engage.setEnabled(True)
if n_shots == 1:
n_shots_string = '(1 shot)'
else:
n_shots_string = '({} shots)'.format(n_shots)
self.ui.pushButton_engage.setText('Engage {}'.format(n_shots_string))
[docs]
def preparse_globals(self):
active_groups = self.get_active_groups()
if active_groups is None:
# There was an error, get_active_groups has already shown
# it to the user.
return
# Expansion mode is automatically updated when the global's
# type changes. If this occurs, we will have to parse again to
# include the change:
while True:
results = self.parse_globals(active_groups, raise_exceptions=False, expand_globals=False, return_dimensions = True)
sequence_globals, shots, evaled_globals, global_hierarchy, expansions, dimensions = results
self.n_shots = len(shots)
expansions_changed = self.guess_expansion_modes(
active_groups, evaled_globals, global_hierarchy, expansions)
if not expansions_changed:
# Now expand globals while parsing to calculate the number of shots.
# this must only be done after the expansion type guessing has been updated to avoid exceptions
# when changing a zip group from a list to a single value
results = self.parse_globals(active_groups, raise_exceptions=False, expand_globals=True, return_dimensions = True)
sequence_globals, shots, evaled_globals, global_hierarchy, expansions, dimensions = results
self.n_shots = len(shots)
break
self.update_tabs_parsing_indication(active_groups, sequence_globals, evaled_globals, self.n_shots)
self.update_axes_tab(expansions, dimensions)
[docs]
def preparse_globals_loop(self):
"""Runs in a thread, waiting on a threading.Event that tells us when
some globals have changed, and calls parse_globals to evaluate them
all before feeding the results back to the relevant tabs to be
displayed."""
while True:
try:
# Wait until we're needed:
self.preparse_globals_required.get()
n_requests = 1
# Wait until the main thread is idle before clearing the queue of
# requests. This way if preparsing is triggered multiple times within
# the main thread before it becomes idle, we can respond to this all at
# once, once they are all done, rather than starting too early and
# having to preparse again.
with qtlock:
while True:
try:
self.preparse_globals_required.get(block=False)
n_requests += 1
except queue.Empty:
break
# Do some work:
self.preparse_globals()
# Tell any callers calling preparse_globals_required.join() that we are
# done with their request:
for _ in range(n_requests):
self.preparse_globals_required.task_done()
except Exception:
# Raise the error, but keep going so we don't take down the
# whole thread if there is a bug.
exc_info = sys.exc_info()
raise_exception_in_thread(exc_info)
[docs]
def wait_until_preparse_complete(self):
"""Block until the preparse loop has finished pending work"""
self.preparse_globals_required.join()
[docs]
def get_group_item_by_name(self, globals_file, group_name, column, previous_name=None):
"""Returns an item from the row representing a globals group in the
groups model. Which item is returned is set by the column argument."""
parent_item = self.groups_model.findItems(globals_file, column=self.GROUPS_COL_NAME)[0]
possible_name_items = self.groups_model.findItems(group_name, QtCore.Qt.MatchRecursive,
column=self.GROUPS_COL_NAME)
# Don't accidentally match on other groups or files with the same name
# as this group:
possible_name_items = [item for item in possible_name_items if item.parent() == parent_item]
if previous_name is not None:
# Also filter by previous name, useful for telling rows apart when
# a rename is in progress and two rows may temporarily contain the
# same name (though the rename code with throw an error and revert
# it).
possible_name_items = [item for item in possible_name_items
if item.data(self.GROUPS_ROLE_PREVIOUS_NAME) == previous_name]
elif group_name != self.GROUPS_DUMMY_ROW_TEXT:
# Don't return the dummy item unless they asked for it explicitly
# - if a new group is being created, its name might be
# simultaneously present in its own row and the dummy row too.
possible_name_items = [item for item in possible_name_items
if not item.data(self.GROUPS_ROLE_IS_DUMMY_ROW)]
if len(possible_name_items) > 1:
raise LookupError('Multiple items found')
elif not possible_name_items:
raise LookupError('No item found')
name_item = possible_name_items[0]
name_index = name_item.index()
# Found the name item, get the sibling item for the column requested:
item_index = name_index.sibling(name_index.row(), column)
item = self.groups_model.itemFromIndex(item_index)
return item
[docs]
def do_model_sort(self):
header = self.ui.treeView_groups.header()
sort_column = header.sortIndicatorSection()
sort_order = header.sortIndicatorOrder()
self.ui.treeView_groups.sortByColumn(sort_column, sort_order)
[docs]
@inmain_decorator() # Can be called from a non-main thread
def get_active_groups(self, interactive=True):
"""Returns active groups in the format {group_name: globals_file}.
Displays an error dialog and returns None if multiple groups of the
same name are selected, this is invalid - selected groups must be
uniquely named. If interactive=False, raises the exception instead."""
active_groups = {}
for i in range(self.groups_model.rowCount()):
file_name_item = self.groups_model.item(i, self.GROUPS_COL_NAME)
for j in range(file_name_item.rowCount()):
group_name_item = file_name_item.child(j, self.GROUPS_COL_NAME)
group_active_item = file_name_item.child(j, self.GROUPS_COL_ACTIVE)
if group_active_item.checkState() == QtCore.Qt.Checked:
group_name = group_name_item.text()
globals_file = file_name_item.text()
if group_name in active_groups:
msg = (
'There are two active groups named %s. ' % group_name
+ 'Active groups must have unique names.'
)
if interactive:
error_dialog(msg)
return
raise RuntimeError(msg)
active_groups[group_name] = globals_file
return active_groups
[docs]
def open_globals_file(self, globals_file):
# Do nothing if this file is already open:
if self.groups_model.findItems(globals_file, column=self.GROUPS_COL_NAME):
return
# Get the groups:
groups = runmanager.get_grouplist(globals_file)
# Add the parent row:
file_name_item = QtGui.QStandardItem(globals_file)
file_name_item.setEditable(False)
file_name_item.setToolTip(globals_file)
# Sort column by name:
file_name_item.setData(globals_file, self.GROUPS_ROLE_SORT_DATA)
file_active_item = QtGui.QStandardItem()
file_active_item.setCheckState(QtCore.Qt.Unchecked)
# Sort column by CheckState - must keep this updated when checkstate changes:
file_active_item.setData(QtCore.Qt.Unchecked, self.GROUPS_ROLE_SORT_DATA)
file_active_item.setEditable(False)
file_active_item.setToolTip('Check to set all the file\'s groups as active.')
file_delete_item = QtGui.QStandardItem() # Blank, only groups have a delete button
file_delete_item.setEditable(False)
# Must be set to something so that the dummy row doesn't get sorted first:
file_delete_item.setData(False, self.GROUPS_ROLE_SORT_DATA)
file_close_item = QtGui.QStandardItem()
file_close_item.setIcon(QtGui.QIcon(':qtutils/fugue/cross'))
file_close_item.setEditable(False)
file_close_item.setToolTip('Close globals file.')
self.groups_model.appendRow([file_name_item, file_active_item, file_delete_item, file_close_item])
# Add the groups as children:
for group_name in groups:
row = self.make_group_row(group_name)
file_name_item.appendRow(row)
# Finally, add the <Click to add group> row at the bottom:
dummy_name_item = QtGui.QStandardItem(self.GROUPS_DUMMY_ROW_TEXT)
dummy_name_item.setToolTip('Click to add group')
# This lets later code know that this row does
# not correspond to an actual globals group:
dummy_name_item.setData(True, self.GROUPS_ROLE_IS_DUMMY_ROW)
dummy_name_item.setData(self.GROUPS_DUMMY_ROW_TEXT, self.GROUPS_ROLE_PREVIOUS_NAME)
dummy_name_item.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable) # Clears the 'selectable' flag
dummy_active_item = QtGui.QStandardItem()
dummy_active_item.setData(True, self.GROUPS_ROLE_IS_DUMMY_ROW)
dummy_active_item.setFlags(QtCore.Qt.NoItemFlags)
dummy_delete_item = QtGui.QStandardItem()
dummy_delete_item.setData(True, self.GROUPS_ROLE_IS_DUMMY_ROW)
dummy_delete_item.setFlags(QtCore.Qt.NoItemFlags)
dummy_open_close_item = QtGui.QStandardItem()
dummy_open_close_item.setData(True, self.GROUPS_ROLE_IS_DUMMY_ROW)
dummy_open_close_item.setFlags(QtCore.Qt.NoItemFlags)
# Not setting anything as the above items' sort role has the effect of
# ensuring this row is always sorted to the end of the list, without
# us having to implement any custom sorting methods or subclassing
# anything, yay.
file_name_item.appendRow([dummy_name_item, dummy_active_item, dummy_delete_item, dummy_open_close_item])
# Expand the child items to be visible:
self.ui.treeView_groups.setExpanded(file_name_item.index(), True)
self.globals_changed()
self.do_model_sort()
# If this changed the sort order, ensure the file item is visible:
scroll_view_to_row_if_current(self.ui.treeView_groups, file_name_item)
[docs]
def make_group_row(self, group_name):
"""Returns a new row representing one group in the groups tab, ready to be
inserted into the model."""
group_name_item = QtGui.QStandardItem(group_name)
# We keep the previous name around so that we can detect what changed:
group_name_item.setData(group_name, self.GROUPS_ROLE_PREVIOUS_NAME)
# Sort column by name:
group_name_item.setData(group_name, self.GROUPS_ROLE_SORT_DATA)
group_active_item = QtGui.QStandardItem()
group_active_item.setCheckState(QtCore.Qt.Unchecked)
# Sort column by CheckState - must keep this updated whenever the
# checkstate changes:
group_active_item.setData(QtCore.Qt.Unchecked, self.GROUPS_ROLE_SORT_DATA)
group_active_item.setEditable(False)
group_active_item.setToolTip(
'Whether or not the globals within this group should be used by runmanager for compilation.')
group_delete_item = QtGui.QStandardItem()
group_delete_item.setIcon(QtGui.QIcon(':qtutils/fugue/minus'))
# Must be set to something so that the dummy row doesn't get sorted first:
group_delete_item.setData(False, self.GROUPS_ROLE_SORT_DATA)
group_delete_item.setEditable(False)
group_delete_item.setToolTip('Delete globals group from file.')
group_open_close_item = QtGui.QStandardItem()
group_open_close_item.setIcon(QtGui.QIcon(':qtutils/fugue/plus'))
group_open_close_item.setData(False, self.GROUPS_ROLE_GROUP_IS_OPEN)
# Sort column by whether group is open - must keep this manually
# updated when the state changes:
group_open_close_item.setData(False, self.GROUPS_ROLE_SORT_DATA)
group_open_close_item.setEditable(False)
group_open_close_item.setToolTip('Load globals group into runmananger.')
row = [group_name_item, group_active_item, group_delete_item, group_open_close_item]
return row
[docs]
def close_globals_file(self, globals_file, confirm=True):
item = self.groups_model.findItems(globals_file, column=self.GROUPS_COL_NAME)[0]
# Close any open groups in this globals file:
child_name_items = [item.child(i, self.GROUPS_COL_NAME) for i in range(item.rowCount())]
child_openclose_items = [item.child(i, self.GROUPS_COL_OPENCLOSE) for i in range(item.rowCount())]
child_is_open = [child_item.data(self.GROUPS_ROLE_GROUP_IS_OPEN)
for child_item in child_openclose_items]
if confirm and any(child_is_open):
if not question_dialog('Close %s? This will close %d currently open group(s).' %
(globals_file, child_is_open.count(True))):
return
to_close = [name_item for name_item, is_open in zip(child_name_items, child_is_open) if is_open]
for name_item in to_close:
group_name = name_item.text()
self.close_group(globals_file, group_name)
# Remove the globals file from the model:
self.groups_model.removeRow(item.row())
self.globals_changed()
[docs]
def copy_group(self, source_globals_file, source_group_name, dest_globals_file=None, delete_source_group=False):
"""This function copys a group of globals with the name source_group_name from the file
source_globals_file to a new file dest_globals_file. If delete_source_group is True
the source group is deleted after copying"""
if delete_source_group and source_globals_file == dest_globals_file:
return
try:
dest_group_name = runmanager.copy_group(source_globals_file, source_group_name, dest_globals_file, delete_source_group)
except Exception as e:
error_dialog(str(e))
else:
# Insert the newly created globals group into the model, as a
# child row of the new globals file.
if dest_globals_file is None:
dest_globals_file = source_globals_file
# find the new groups parent row by filepath
for index in range(self.groups_model.rowCount()):
if self.groups_model.item(index, self.GROUPS_COL_NAME).text() == dest_globals_file:
parent_row = self.groups_model.item(index)
break
last_index = parent_row.rowCount()
# Insert it as the row before the last (dummy) row:
group_row = self.make_group_row(dest_group_name)
parent_row.insertRow(last_index - 1, group_row)
self.do_model_sort()
# Open the group
self.open_group(dest_globals_file, dest_group_name)
name_item = group_row[self.GROUPS_COL_NAME]
self.globals_changed()
self.ui.treeView_groups.setCurrentIndex(name_item.index())
# delete original
if delete_source_group:
self.delete_group(source_globals_file, source_group_name, confirm=False)
# If this changed the sort order, ensure the group item is still visible:
scroll_view_to_row_if_current(self.ui.treeView_groups, name_item)
[docs]
def new_group(self, globals_file, group_name):
item = self.get_group_item_by_name(globals_file, group_name, self.GROUPS_COL_NAME,
previous_name=self.GROUPS_DUMMY_ROW_TEXT)
try:
runmanager.new_group(globals_file, group_name)
except Exception as e:
error_dialog(str(e))
else:
# Insert the newly created globals group into the model, as a
# child row of the globals file it belong to.
group_row = self.make_group_row(group_name)
last_index = item.parent().rowCount()
# Insert it as the row before the last (dummy) row:
item.parent().insertRow(last_index - 1, group_row)
self.do_model_sort()
# Open the group and mark it active:
self.open_group(globals_file, group_name)
active_item = group_row[self.GROUPS_COL_ACTIVE]
name_item = group_row[self.GROUPS_COL_NAME]
active_item.setCheckState(QtCore.Qt.Checked)
self.globals_changed()
self.ui.treeView_groups.setCurrentIndex(name_item.index())
# If this changed the sort order, ensure the group item is still visible:
scroll_view_to_row_if_current(self.ui.treeView_groups, name_item)
finally:
# Set the dummy row's text back ready for another group to be created:
item.setText(self.GROUPS_DUMMY_ROW_TEXT)
[docs]
def open_group(self, globals_file, group_name, trigger_preparse=True):
assert (globals_file, group_name) not in self.currently_open_groups # sanity check
group_tab = GroupTab(self.ui.tabWidget, globals_file, group_name)
self.currently_open_groups[globals_file, group_name] = group_tab
# Set the open/close state in the groups_model. itemChanged will be
# emitted and self.on_groups_model_item_changed will handle updating
# the other data roles, icons etc:
openclose_item = self.get_group_item_by_name(globals_file, group_name, self.GROUPS_COL_OPENCLOSE)
openclose_item.setData(True, self.GROUPS_ROLE_GROUP_IS_OPEN)
# Trigger a preparse to occur in light of this. Calling code can
# disable this so that multiple groups can be opened at once without
# triggering a preparse. If they do so, they should call
# self.globals_changed() themselves.
if trigger_preparse:
self.globals_changed()
[docs]
def rename_group(self, globals_file, previous_group_name, new_group_name):
item = self.get_group_item_by_name(globals_file, new_group_name, self.GROUPS_COL_NAME,
previous_name=previous_group_name)
try:
runmanager.rename_group(globals_file, previous_group_name, new_group_name)
except Exception as e:
error_dialog(str(e))
# Set the item text back to the old name, since the rename failed:
item.setText(previous_group_name)
else:
item.setData(new_group_name, self.GROUPS_ROLE_PREVIOUS_NAME)
item.setData(new_group_name, self.GROUPS_ROLE_SORT_DATA)
self.do_model_sort()
# If this changed the sort order, ensure the group item is still visible:
scroll_view_to_row_if_current(self.ui.treeView_groups, item)
group_tab = self.currently_open_groups.pop((globals_file, previous_group_name), None)
if group_tab is not None:
# Change labels and tooltips appropriately if the group is open:
group_tab.set_file_and_group_name(globals_file, new_group_name)
# Re-add it to the dictionary under the new name:
self.currently_open_groups[globals_file, new_group_name] = group_tab
[docs]
def close_group(self, globals_file, group_name):
group_tab = self.currently_open_groups.pop((globals_file, group_name), None)
assert group_tab is not None # Just in case
group_tab.close()
openclose_item = self.get_group_item_by_name(globals_file, group_name, self.GROUPS_COL_OPENCLOSE)
openclose_item.setData(False, self.GROUPS_ROLE_GROUP_IS_OPEN)
[docs]
def delete_group(self, globals_file, group_name, confirm=True):
if confirm:
if not question_dialog("Delete the group '%s'?" % group_name):
return
# If the group is open, close it:
group_tab = self.currently_open_groups.get((globals_file, group_name))
if group_tab is not None:
self.close_group(globals_file, group_name)
runmanager.delete_group(globals_file, group_name)
# Find the entry for this group in self.groups_model and remove it:
name_item = self.get_group_item_by_name(globals_file, group_name, self.GROUPS_COL_NAME)
name_item.parent().removeRow(name_item.row())
self.globals_changed()
[docs]
def on_save_configuration_triggered(self):
if self.last_save_config_file is None:
self.on_save_configuration_as_triggered()
self.ui.actionSave_configuration_as.setEnabled(True)
self.ui.actionRevert_configuration.setEnabled(True)
else:
self.save_configuration(self.last_save_config_file)
[docs]
def on_revert_configuration_triggered(self):
save_data = self.get_save_data()
if self.last_save_data is not None and save_data != self.last_save_data:
message = 'Revert configuration to the last saved state in \'%s\'?' % self.last_save_config_file
reply = QtWidgets.QMessageBox.question(self.ui, 'Load configuration', message,
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel)
if reply == QtWidgets.QMessageBox.Cancel:
return
elif reply == QtWidgets.QMessageBox.Yes:
self.load_configuration(self.last_save_config_file)
else:
error_dialog('no changes to revert')
[docs]
def on_save_configuration_as_triggered(self):
if self.last_save_config_file is not None:
default = self.last_save_config_file
else:
default = os.path.join(self.exp_config.get('paths', 'experiment_shot_storage'), 'runmanager.ini')
save_file = QtWidgets.QFileDialog.getSaveFileName(self.ui,
'Select file to save current runmanager configuration',
default,
"config files (*.ini)")
if type(save_file) is tuple:
save_file, _ = save_file
if not save_file:
# User cancelled
return
# Convert to standard platform specific path, otherwise Qt likes
# forward slashes:
save_file = os.path.abspath(save_file)
self.save_configuration(save_file)
self.ui.actionSave_configuration.setText('Save configuration {}'.format(save_file))
[docs]
def get_save_data(self):
# Get the currently open files and active groups:
h5_files_open = []
active_groups = []
for i in range(self.groups_model.rowCount()):
file_name_item = self.groups_model.item(i, self.GROUPS_COL_NAME)
globals_file_name = file_name_item.text()
h5_files_open.append(globals_file_name)
for j in range(file_name_item.rowCount()):
group_name_item = file_name_item.child(j, self.GROUPS_COL_NAME)
group_name = group_name_item.text()
group_active_item = file_name_item.child(j, self.GROUPS_COL_ACTIVE)
if group_active_item.checkState() == QtCore.Qt.Checked:
active_groups.append((globals_file_name, group_name))
# Get the currently open groups:
groups_open = []
for i in range(self.ui.tabWidget.count()):
tab_page = self.ui.tabWidget.widget(i)
for (globals_file_name, group_name), group_tab in self.currently_open_groups.items():
if group_tab.ui is tab_page:
groups_open.append((globals_file_name, group_name))
break
# Get the labscript file, output folder, and whether the output folder
# is default:
current_labscript_file = self.ui.lineEdit_labscript_file.text()
shot_output_folder = self.ui.lineEdit_shot_output_folder.text()
is_using_default_shot_output_folder = (shot_output_folder == self.get_default_output_folder())
# Only save the shot output folder if not using the default, that way
# the folder updating as the day rolls over will not be detected as a
# change to the save data:
if is_using_default_shot_output_folder:
shot_output_folder = ''
# Get the server hostnames:
blacs_host = self.ui.lineEdit_BLACS_hostname.text()
send_to_runviewer = self.ui.checkBox_view_shots.isChecked()
send_to_blacs = self.ui.checkBox_run_shots.isChecked()
shuffle = self.ui.pushButton_shuffle.isChecked()
# axes tab information
axes = []
for i in range(self.axes_model.rowCount()):
name_item = self.axes_model.item(i, self.AXES_COL_NAME)
shuffle_item = self.axes_model.item(i, self.AXES_COL_SHUFFLE)
shuffle_state = shuffle_item.checkState()
axes.append((name_item.data(self.AXES_ROLE_NAME), 1 if shuffle_state == QtCore.Qt.Checked else 0))
save_data = {'h5_files_open': h5_files_open,
'active_groups': active_groups,
'groups_open': groups_open,
'current_labscript_file': current_labscript_file,
'shot_output_folder': shot_output_folder,
'is_using_default_shot_output_folder': is_using_default_shot_output_folder,
'send_to_runviewer': send_to_runviewer,
'send_to_blacs': send_to_blacs,
'shuffle': shuffle,
'axes': axes,
'blacs_host': blacs_host}
return save_data
[docs]
def save_configuration(self, save_file):
save_data = self.get_save_data()
self.last_save_config_file = save_file
self.last_save_data = save_data
save_appconfig(save_file, {'runmanager_state': save_data})
[docs]
def on_load_configuration_triggered(self):
save_data = self.get_save_data()
if self.last_save_data is not None and save_data != self.last_save_data:
message = ('Current configuration (which groups are active/open and other GUI state) '
'has changed: save config file \'%s\'?' % self.last_save_config_file)
reply = QtWidgets.QMessageBox.question(self.ui, 'Load configuration', message,
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Cancel)
if reply == QtWidgets.QMessageBox.Cancel:
return
if reply == QtWidgets.QMessageBox.Yes:
self.save_configuration(self.last_save_config_file)
if self.last_save_config_file is not None:
default = self.last_save_config_file
else:
default = os.path.join(self.exp_config.get('paths', 'experiment_shot_storage'), 'runmanager.ini')
file = QtWidgets.QFileDialog.getOpenFileName(self.ui,
'Select runmanager configuration file to load',
default,
"config files (*.ini)")
if type(file) is tuple:
file, _ = file
if not file:
# User cancelled
return
# Convert to standard platform specific path, otherwise Qt likes
# forward slashes:
file = os.path.abspath(file)
self.load_configuration(file)
[docs]
def load_configuration(self, filename):
self.last_save_config_file = filename
self.ui.actionSave_configuration.setText('Save configuration %s'%filename)
# Close all files:
save_data = self.get_save_data()
for globals_file in save_data['h5_files_open']:
self.close_globals_file(globals_file, confirm=False)
# Ensure folder exists, if this was opened programmatically we are
# creating the file, so the directory had better exist!
runmanager_config = load_appconfig(filename).get('runmanager_state', {})
has_been_a_warning = [False]
def warning(message):
if not has_been_a_warning[0]:
has_been_a_warning[0] = True
self.output_box.output('\n')
self.output_box.output('Warning: %s\n' % message, red=True)
for globals_file in runmanager_config.get('h5_files_open', []):
if os.path.exists(globals_file):
try:
self.open_globals_file(globals_file)
self.last_opened_globals_folder = os.path.dirname(globals_file)
except Exception:
raise_exception_in_thread(sys.exc_info())
continue
else:
self.output_box.output('\nWarning: globals file %s no longer exists\n' % globals_file, red=True)
for globals_file, group_name in runmanager_config.get('active_groups', []):
try:
group_active_item = self.get_group_item_by_name(globals_file, group_name, self.GROUPS_COL_ACTIVE)
group_active_item.setCheckState(QtCore.Qt.Checked)
except LookupError:
warning("previously active group '%s' in %s no longer exists" % (group_name, globals_file))
for globals_file, group_name in runmanager_config.get('groups_open', []):
# First check if it exists:
try:
self.get_group_item_by_name(globals_file, group_name, self.GROUPS_COL_NAME)
except LookupError:
warning("previously open group '%s' in %s no longer exists" % (group_name, globals_file))
else:
self.open_group(globals_file, group_name)
current_labscript_file = runmanager_config.get('current_labscript_file')
if current_labscript_file is not None:
if os.path.exists(current_labscript_file):
self.ui.lineEdit_labscript_file.setText(current_labscript_file)
self.last_opened_labscript_folder = os.path.dirname(current_labscript_file)
elif current_labscript_file:
warning('previously selected labscript file %s no longer exists' % current_labscript_file)
shot_output_folder = runmanager_config.get('shot_output_folder')
if shot_output_folder is not None:
self.ui.lineEdit_shot_output_folder.setText(shot_output_folder)
self.last_selected_shot_output_folder = os.path.dirname(shot_output_folder)
if runmanager_config.get('is_using_default_shot_output_folder', False):
default_output_folder = self.get_default_output_folder()
self.ui.lineEdit_shot_output_folder.setText(default_output_folder)
self.last_selected_shot_output_folder = os.path.dirname(default_output_folder)
send_to_runviewer = runmanager_config.get('send_to_runviewer')
if send_to_runviewer is not None:
self.ui.checkBox_view_shots.setChecked(send_to_runviewer)
send_to_blacs = runmanager_config.get('send_to_blacs')
if send_to_blacs is not None:
self.ui.checkBox_run_shots.setChecked(send_to_blacs)
# clear the axes model first
if self.axes_model.rowCount():
self.axes_model.removeRows(0, self.axes_model.rowCount())
# set the state of the global shuffle button. This ensure that if no axes items get loaded afterwards
# (e.g. because the globals in the .ini file are no longer expansion globals), then we still have
# an approximate state for the shuffle button that will apply to whatever globals are to be expanded.
if runmanager_config.get('shuffle', False):
self.ui.pushButton_shuffle.setChecked(True)
# Now load the axes states (order and shuffle). This will also ensure the shuffle button matches the
# state of these items (since we don't save/restore the tri-state nature of the global shuffle button
axes = runmanager_config.get('axes')
if axes is not None and isinstance(axes, list):
# clear model
for name, shuffle in axes:
self.add_item_to_axes_model(name, shuffle)
self.update_axes_indentation()
blacs_host = runmanager_config.get('blacs_host')
if blacs_host is not None:
self.ui.lineEdit_BLACS_hostname.setText(blacs_host)
# Set as self.last_save_data:
save_data = self.get_save_data()
self.last_save_data = save_data
self.ui.actionSave_configuration_as.setEnabled(True)
self.ui.actionRevert_configuration.setEnabled(True)
[docs]
def compile_loop(self):
while True:
try:
labscript_file, run_files, send_to_BLACS, BLACS_host, send_to_runviewer = self.compile_queue.get()
run_files = iter(run_files) # Should already be in iterator but just in case
while True:
if self.compilation_aborted.is_set():
self.output_box.output('Compilation aborted.\n\n', red=True)
break
try:
try:
# We do next() instead of looping over run_files
# so that if compilation is aborted we won't
# create an extra file unnecessarily.
run_file = next(run_files)
except StopIteration:
self.output_box.output('Ready.\n\n')
break
else:
self.to_child.put(['compile', [labscript_file, run_file]])
signal, success = self.from_child.get()
assert signal == 'done'
if not success:
self.compilation_aborted.set()
continue
if send_to_BLACS:
self.send_to_BLACS(run_file, BLACS_host)
if send_to_runviewer:
self.send_to_runviewer(run_file)
except Exception as e:
self.output_box.output(str(e) + '\n', red=True)
self.compilation_aborted.set()
inmain(self.ui.pushButton_abort.setEnabled, False)
self.compilation_aborted.clear()
except Exception:
# Raise it so whatever bug it is gets seen, but keep going so
# the thread keeps functioning:
exc_info = sys.exc_info()
raise_exception_in_thread(exc_info)
continue
[docs]
def parse_globals(self, active_groups, raise_exceptions=True, expand_globals=True, expansion_order = None, return_dimensions = False):
sequence_globals = runmanager.get_globals(active_groups)
#logger.info('got sequence globals')
evaled_globals, global_hierarchy, expansions = runmanager.evaluate_globals(sequence_globals, raise_exceptions)
#logger.info('evaluated sequence globals')
if expand_globals:
if return_dimensions:
shots, dimensions = runmanager.expand_globals(sequence_globals, evaled_globals, expansion_order, return_dimensions=return_dimensions)
else:
shots = runmanager.expand_globals(sequence_globals, evaled_globals, expansion_order)
else:
shots = []
dimensions = {}
#logger.info('expanded sequence globals')
if return_dimensions:
return sequence_globals, shots, evaled_globals, global_hierarchy, expansions, dimensions
else:
return sequence_globals, shots, evaled_globals, global_hierarchy, expansions
[docs]
def guess_expansion_modes(self, active_groups, evaled_globals, global_hierarchy, expansions):
"""This function is designed to be called iteratively. It changes the
expansion type of globals that reference other globals - such that
globals referencing an iterable global will be zipped with it, rather
than outer producted. Each time this method is called,
self.parse_globals should also be called, so that the globals are
evaluated with their new expansion modes, if they changed. This should
be performed repeatedly until there are no more changes. Note that
this method does not return what expansion types it thinks globals
should have - it *actually writes them to the globals HDF5 file*. So
it is up to later code to ensure it re-reads the expansion mode from
the HDF5 file before proceeding. At present this method is only called
from self.preparse_globals(), so see there to see how it fits in with
everything else. This method uses four instance attributes to store
state: self.previous_evaled_globals, self.previous_global_hierarchy,
self.previous_expansion_types and self.previous_expansions. This is
neccesary so that it can detect changes."""
# Do nothing if there were exceptions:
for group_name in evaled_globals:
for global_name in evaled_globals[group_name]:
value = evaled_globals[group_name][global_name]
if isinstance(value, Exception):
# Let ExpansionErrors through through, as they occur
# when the user has changed the value without changing
# the expansion type:
if isinstance(value, runmanager.ExpansionError):
continue
return False
# Did the guessed expansion type for any of the globals change?
expansion_types_changed = False
expansion_types = {}
for group_name in evaled_globals:
for global_name in evaled_globals[group_name]:
new_value = evaled_globals[group_name][global_name]
try:
previous_value = self.previous_evaled_globals[group_name][global_name]
except KeyError:
# This variable is used to guess the expansion type
#
# If we already have an expansion specified for this, but
# don't have a previous value, then we should use the
# new_value for the guess as we are likely loading from HDF5
# file for the first time (and either way, don't want to
# overwrite what the user has put in the expansion type)
#
# If we don't have an expansion...
# then we set it to '0' which will result in an
# expansion type guess of '' (emptys string) This will
# either result in nothing being done to the expansion
# type or the expansion type being found to be 'outer',
# which will then make it go through the machinery below
if global_name in expansions and expansions[global_name]:
previous_value = new_value
else:
previous_value = 0
new_guess = runmanager.guess_expansion_type(new_value)
previous_guess = runmanager.guess_expansion_type(previous_value)
if new_guess == 'outer':
expansion_types[global_name] = {'previous_guess': previous_guess,
'new_guess': new_guess,
'group_name': group_name,
'value': new_value
}
elif new_guess != previous_guess:
filename = active_groups[group_name]
runmanager.set_expansion(filename, group_name, global_name, new_guess)
expansions[global_name] = new_guess
expansion_types_changed = True
# recursively find dependencies and add them to a zip group!
def find_dependencies(global_name, global_hierarchy, expansion_types):
results = set()
for name, dependencies in global_hierarchy.items():
if name in expansion_types and global_name in dependencies:
results.add(name)
results = results.union(find_dependencies(name, global_hierarchy, expansion_types))
return results
def global_depends_on_global_with_outer_product(global_name, global_hierarchy, expansions):
if global_name not in global_hierarchy:
return False
else:
for dependency in global_hierarchy[global_name]:
if expansions[dependency]:
return True
def set_expansion_type_guess(expansion_types, expansions, global_name, expansion_to_set, new=True):
if new:
key = 'new_guess'
else:
key = 'previous_guess'
# debug logging
log_if_global(global_name, [], 'setting expansion type for new dependency' if new else 'setting expansion type for old dependencies')
# only do this if the expansion is *not* already set to a specific zip group
if global_name in expansions and expansions[global_name] != '' and expansions[global_name] != 'outer':
expansion_types[global_name][key] = expansions[global_name]
# debug logging
log_if_global(global_name, [], 'Using existing expansion %s for %s'%(expansions[global_name], global_name))
else:
expansion_types[global_name][key] = expansion_to_set
expansions[global_name] = expansion_to_set
# debug logging
log_if_global(global_name, [], 'Using existing expansion %s for %s'%(expansion_to_set, global_name))
for global_name in sorted(expansion_types):
# we have a global that does not depend on anything that has an
# expansion type of 'outer'
if (not global_depends_on_global_with_outer_product(global_name, global_hierarchy, expansions)
and not isinstance(expansion_types[global_name]['value'], runmanager.ExpansionError)):
current_dependencies = find_dependencies(global_name, global_hierarchy, expansion_types)
# if this global has other globals that use it, then add them
# all to a zip group with the name of this global
if current_dependencies:
for dependency in current_dependencies:
set_expansion_type_guess(expansion_types, expansions, dependency, str(global_name))
set_expansion_type_guess(expansion_types, expansions, global_name, str(global_name))
for global_name in sorted(self.previous_expansion_types):
if (not global_depends_on_global_with_outer_product(
global_name, self.previous_global_hierarchy, self.previous_expansions)
and not isinstance(self.previous_expansion_types[global_name]['value'], runmanager.ExpansionError)):
old_dependencies = find_dependencies(global_name, self.previous_global_hierarchy, self.previous_expansion_types)
# if this global has other globals that use it, then add them
# all to a zip group with the name of this global
if old_dependencies:
for dependency in old_dependencies:
if dependency in expansion_types:
set_expansion_type_guess(expansion_types, self.previous_expansions, dependency, str(global_name), new=False)
if global_name in expansion_types:
set_expansion_type_guess(expansion_types, self.previous_expansions, global_name, str(global_name), new=False)
for global_name, guesses in expansion_types.items():
if guesses['new_guess'] != guesses['previous_guess']:
filename = active_groups[guesses['group_name']]
runmanager.set_expansion(
filename, str(guesses['group_name']), str(global_name), str(guesses['new_guess']))
expansions[global_name] = guesses['new_guess']
expansion_types_changed = True
# Now check everything that has an expansion type not equal to outer.
# If it has one, but is not iteratble, remove it from teh zip group
for group_name in evaled_globals:
for global_name in evaled_globals[group_name]:
if expansions[global_name] and expansions[global_name] != 'outer':
try:
iter(evaled_globals[group_name][global_name])
except Exception:
filename = active_groups[group_name]
runmanager.set_expansion(filename, group_name, global_name, '')
expansion_types_changed = True
self.previous_evaled_globals = evaled_globals
self.previous_global_hierarchy = global_hierarchy
self.previous_expansion_types = expansion_types
self.previous_expansions = expansions
return expansion_types_changed
[docs]
def make_h5_files(self, labscript_file, output_folder, sequence_globals, shots, shuffle):
sequence_attrs, default_output_dir, filename_prefix = runmanager.new_sequence_details(
labscript_file, config=self.exp_config, increment_sequence_index=True
)
if output_folder == self.previous_default_output_folder:
# The user is using dthe efault output folder. Just in case the sequence
# index has been updated or the date has changed, use the default_output dir
# obtained from new_sequence_details, as it is race-free, whereas the one
# from the UI may be out of date since we only update it once a second.
output_folder = default_output_dir
self.check_output_folder_update()
run_files = runmanager.make_run_files(
output_folder,
sequence_globals,
shots,
sequence_attrs,
filename_prefix,
shuffle,
)
logger.debug(run_files)
return labscript_file, run_files
[docs]
def send_to_BLACS(self, run_file, BLACS_hostname):
port = int(self.exp_config.get('ports', 'BLACS'))
agnostic_path = shared_drive.path_to_agnostic(run_file)
self.output_box.output('Submitting run file %s.\n' % os.path.basename(run_file))
try:
response = zmq_get(port, BLACS_hostname, data=agnostic_path)
if 'added successfully' in response:
self.output_box.output(response)
else:
raise Exception(response)
except Exception as e:
self.output_box.output('Couldn\'t submit job to control server: %s\n' % str(e), red=True)
self.compilation_aborted.set()
[docs]
def send_to_runviewer(self, run_file):
runviewer_port = int(self.exp_config.get('ports', 'runviewer'))
agnostic_path = shared_drive.path_to_agnostic(run_file)
try:
response = zmq_get(runviewer_port, 'localhost', data='hello', timeout=1)
if 'hello' not in response:
raise Exception(response)
except Exception as e:
logger.info('runviewer not running, attempting to start...')
# Runviewer not running, start it:
if os.name == 'nt':
creationflags = 0x00000008 # DETACHED_PROCESS from the win32 API
scripts_dir = desktop_app.environment.get_scripts_dir('runviewer')
subprocess.Popen([str(scripts_dir / 'runviewer-gui')],
creationflags=creationflags, stdout=None, stderr=None,
close_fds=True)
else:
devnull = open(os.devnull, 'w')
if not os.fork():
os.setsid()
subprocess.Popen([sys.executable, '-m', 'runviewer'],
stdin=devnull, stdout=devnull, stderr=devnull, close_fds=True)
os._exit(0)
try:
zmq_get(runviewer_port, 'localhost', data='hello', timeout=15)
except Exception as e:
self.output_box.output('Couldn\'t submit shot to runviewer: %s\n\n' % str(e), red=True)
try:
response = zmq_get(runviewer_port, 'localhost', data=agnostic_path, timeout=0.5)
if 'ok' not in response:
raise Exception(response)
else:
self.output_box.output('Shot %s sent to runviewer.\n' % os.path.basename(run_file))
except Exception as e:
self.output_box.output('Couldn\'t submit shot to runviewer: %s\n\n' % str(e), red=True)
[docs]
class RemoteServer(ZMQServer):
[docs]
def __init__(self):
port = app.exp_config.getint(
'ports', 'runmanager', fallback=runmanager.remote.DEFAULT_PORT
)
ZMQServer.__init__(self, port=port)
[docs]
def handle_get_globals(self, raw=False):
active_groups = inmain(app.get_active_groups, interactive=False)
sequence_globals = runmanager.get_globals(active_groups)
all_globals = {}
if raw:
for group_globals in sequence_globals.values():
values_only = {name: val for name, (val, _, _) in group_globals.items()}
all_globals.update(values_only)
else:
evaled_globals, global_hierarchy, expansions = runmanager.evaluate_globals(
sequence_globals, raise_exceptions=False
)
for group_globals in evaled_globals.values():
all_globals.update(group_globals)
return all_globals
[docs]
@inmain_decorator()
def handle_set_globals(self, globals, raw=False):
active_groups = app.get_active_groups(interactive=False)
sequence_globals = runmanager.get_globals(active_groups)
try:
for global_name, new_value in globals.items():
# Unless raw=True, convert to str representation for saving to the GUI
# or file. If this does not result in an object the user can actually
# use, evaluation will error and the caller will find out about it later
if not raw:
new_value = repr(new_value)
elif not isinstance(new_value, (str, bytes)):
msg = "global %s must be a string if raw=True, not %s"
raise TypeError(msg % (global_name, new_value.__class__.__name__))
# Find the group this global is in:
for group_name, group_globals in sequence_globals.items():
globals_file = active_groups[group_name]
if global_name in group_globals:
# Confirm it's not also in another group:
for other_name, other_globals in sequence_globals.items():
if other_globals is not group_globals:
if global_name in other_globals:
msg = """Cannot set global %s, it is defined in
multiple active groups: %s and %s"""
msg = msg % (global_name, group_name, other_name)
raise RuntimeError(dedent(msg))
previous_value, _, _ = sequence_globals[group_name][global_name]
# Append expression-final comments in the previous expression to
# the new one:
comments = runmanager.find_comments(previous_value)
if comments:
# Only the final comment
comment_start, comment_end = comments[-1]
# Only if the comment is the last thing in the expression:
if comment_end == len(previous_value):
new_value += previous_value[comment_start:comment_end]
try:
# Is the group open?
group_tab = app.currently_open_groups[
globals_file, group_name
]
except KeyError:
# Group is not open. Change the global value on disk:
runmanager.set_value(
globals_file, group_name, global_name, new_value
)
else:
# Group is open. Change the global value via the GUI:
group_tab.change_global_value(
global_name,
previous_value,
new_value,
interactive=False,
)
break
else:
# Global was not found.
msg = "Global %s not found in any active group" % global_name
raise ValueError(msg)
finally:
# Trigger preparsing of globals to occur so that changes in globals not in
# open tabs are reflected in the GUI, such as n_shots, errors on other
# globals that depend on them, etc.
app.globals_changed()
[docs]
def handle_engage(self):
app.wait_until_preparse_complete()
inmain(app.on_engage_clicked)
[docs]
@inmain_decorator()
def handle_abort(self):
app.on_abort_clicked()
[docs]
@inmain_decorator()
def handle_get_run_shots(self):
return app.ui.checkBox_run_shots.isChecked()
[docs]
@inmain_decorator()
def handle_set_run_shots(self, value):
app.ui.checkBox_run_shots.setChecked(value)
[docs]
@inmain_decorator()
def handle_get_view_shots(self):
return app.ui.checkBox_view_shots.isChecked()
[docs]
@inmain_decorator()
def handle_set_view_shots(self, value):
app.ui.checkBox_view_shots.setChecked(value)
[docs]
@inmain_decorator()
def handle_get_shuffle(self):
return app.ui.pushButton_shuffle.isChecked()
[docs]
@inmain_decorator()
def handle_set_shuffle(self, value):
app.ui.pushButton_shuffle.setChecked(value)
[docs]
def handle_n_shots(self):
# Wait until any current preparsing is done, to ensure this is not racy w.r.t
# previous remote calls:
app.wait_until_preparse_complete()
return app.n_shots
[docs]
@inmain_decorator()
def handle_get_labscript_file(self):
labscript_file = app.ui.lineEdit_labscript_file.text()
return os.path.abspath(labscript_file)
[docs]
@inmain_decorator()
def handle_set_labscript_file(self, value):
labscript_file = os.path.abspath(value)
app.ui.lineEdit_labscript_file.setText(labscript_file)
[docs]
@inmain_decorator()
def handle_get_shot_output_folder(self):
shot_output_folder = app.ui.lineEdit_shot_output_folder.text()
return os.path.abspath(shot_output_folder)
[docs]
@inmain_decorator()
def handle_set_shot_output_folder(self, value):
shot_output_folder = os.path.abspath(value)
app.ui.lineEdit_shot_output_folder.setText(shot_output_folder)
[docs]
def handle_error_in_globals(self):
try:
# This will raise an exception if there are multiple active groups of the
# same name:
active_groups = inmain(app.get_active_groups, interactive=False)
sequence_globals = runmanager.get_globals(active_groups)
# This will raise an exception if any of the globals can't be evaluated:
runmanager.evaluate_globals(sequence_globals, raise_exceptions=True)
except Exception:
return True
return False
[docs]
def handle_is_output_folder_default(self):
return not app.non_default_folder
[docs]
@inmain_decorator()
def handle_reset_shot_output_folder(self):
app.on_reset_shot_output_folder_clicked(None)
[docs]
def handler(self, request_data):
cmd, args, kwargs = request_data
if cmd == 'hello':
return 'hello'
elif cmd == '__version__':
return runmanager.__version__
try:
return getattr(self, 'handle_' + cmd)(*args, **kwargs)
except Exception as e:
msg = traceback.format_exc()
msg = "Runmanager server returned an exception:\n" + msg
return e.__class__(msg)
if __name__ == "__main__":
logger = setup_logging('runmanager')
labscript_utils.excepthook.set_logger(logger)
logger.info('\n\n===============starting===============\n')
qapplication = QtWidgets.QApplication.instance()
if qapplication is None:
qapplication = QtWidgets.QApplication(sys.argv)
qapplication.setAttribute(QtCore.Qt.AA_DontShowIconsInMenus, False)
app = RunManager()
splash.update_text('Starting remote server')
remote_server = RemoteServer()
splash.hide()
# Let the interpreter run every 500ms so it sees Ctrl-C interrupts:
timer = QtCore.QTimer()
timer.start(500)
timer.timeout.connect(lambda: None)
# Upon seeing a ctrl-c interrupt, quit the event loop
signal.signal(signal.SIGINT, lambda *args: qapplication.exit())
qapplication.exec_()
remote_server.shutdown()