From fead9e5e0d375d5a4e1a5672030a4fc591e21e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sun, 16 Mar 2025 00:11:16 +0100 Subject: [PATCH 1/5] Add pencil sign if the argument is modified #5 --- artiq/gui/entries.py | 244 ++++++++++++++++++++++++++++++++++++++----- artiq/gui/pencil.svg | 5 + 2 files changed, 223 insertions(+), 26 deletions(-) create mode 100644 artiq/gui/pencil.svg diff --git a/artiq/gui/entries.py b/artiq/gui/entries.py index e6b71ff2ba..379f86c065 100644 --- a/artiq/gui/entries.py +++ b/artiq/gui/entries.py @@ -1,9 +1,11 @@ import logging +import os from collections import OrderedDict from functools import partial from PyQt5 import QtCore, QtGui, QtWidgets +from artiq import __artiq_dir__ as artiq_dir from artiq.gui.tools import LayoutWidget, disable_scroll_wheel, WheelFilter from artiq.gui.scanwidget import ScanWidget from artiq.gui.scientific_spinbox import ScientificSpinBox @@ -12,6 +14,27 @@ logger = logging.getLogger(__name__) +class ModifiedValueLabel(QtWidgets.QLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.pixmap = QtGui.QPixmap(os.path.join(artiq_dir, "gui", "pencil.svg")) + original_size = self.pixmap.size() + self.empty_pixmap = QtGui.QPixmap(original_size) + self.empty_pixmap.fill(QtCore.Qt.transparent) + self.setPixmap(self.empty_pixmap) + self.setToolTip("Non-default value set") + + def setVisible_(self, visible: bool): + """ + Cannot override setVisible, as it's always called + with True argument on startup + """ + if visible: + self.setPixmap(self.pixmap) + else: + self.setPixmap(self.empty_pixmap) + + class EntryTreeWidget(QtWidgets.QTreeWidget): quickStyleClicked = QtCore.pyqtSignal() @@ -85,6 +108,9 @@ def set_argument(self, key, argument): QtWidgets.QStyle.SP_BrowserReload)) reset_entry.clicked.connect(partial(self.reset_entry, key)) + modified_value_icon = ModifiedValueLabel() + widgets["modified_value_icon"] = modified_value_icon + disable_other_scans = QtWidgets.QToolButton() widgets["disable_other_scans"] = disable_other_scans disable_other_scans.setIcon( @@ -95,11 +121,16 @@ def set_argument(self, key, argument): partial(self._disable_other_scans, key)) if not isinstance(entry, ScanEntry): disable_other_scans.setVisible(False) + entry.modifiedValue.connect(modified_value_icon.setVisible_) + else: + for widget in entry.widgets.values(): + widget.modifiedValue.connect(modified_value_icon.setVisible_) tool_buttons = LayoutWidget() tool_buttons.layout.setRowStretch(0, 1) tool_buttons.layout.setRowStretch(3, 1) - tool_buttons.addWidget(reset_entry, 1) + tool_buttons.addWidget(reset_entry, 1, 0) + tool_buttons.addWidget(modified_value_icon, 1, 1) tool_buttons.addWidget(disable_other_scans, 2) self.setItemWidget(widget_item, 2, tool_buttons) @@ -130,7 +161,16 @@ def update_argument(self, key, argument): # results in a bug. widgets["entry"].deleteLater() - widgets["entry"] = procdesc_to_entry(argument["desc"])(argument) + new_entry = procdesc_to_entry(argument["desc"])(argument) + widgets["entry"] = new_entry + if "modified_value_icon" in widgets: + widgets["modified_value_icon"].setVisible_(False) + if not isinstance(new_entry, ScanEntry): + new_entry.modifiedValue.connect(widgets["modified_value_icon"].setVisible_) + else: + for widget in new_entry.widgets.values(): + widget.modifiedValue.connect(widgets["modified_value_icon"].setVisible_) + widgets["disable_other_scans"].setVisible( isinstance(widgets["entry"], ScanEntry)) widgets["fix_layout"].deleteLater() @@ -164,13 +204,26 @@ def restore_state(self, state): class StringEntry(QtWidgets.QLineEdit): + modifiedValue = QtCore.pyqtSignal(bool) + def __init__(self, argument): QtWidgets.QLineEdit.__init__(self) self.setText(argument["state"]) + self.procdesc = argument["desc"] + def update(text): argument["state"] = text + self.modifiedValue.emit(not self.is_default()) self.textEdited.connect(update) + def is_default(self): + value = self.text() + default_value = self.default_state(self.procdesc) + if value != default_value: + return False + else: + return True + @staticmethod def state_to_value(state): return state @@ -181,13 +234,26 @@ def default_state(procdesc): class BooleanEntry(QtWidgets.QCheckBox): + modifiedValue = QtCore.pyqtSignal(bool) + def __init__(self, argument): QtWidgets.QCheckBox.__init__(self) self.setChecked(argument["state"]) + self.procdesc = argument["desc"] + def update(checked): argument["state"] = bool(checked) + self.modifiedValue.emit(not self.is_default()) self.stateChanged.connect(update) + def is_default(self): + value = bool(self.isChecked()) + default_value = bool(self.default_state(self.procdesc)) + if value != default_value: + return False + else: + return True + @staticmethod def state_to_value(state): return state @@ -198,15 +264,16 @@ def default_state(procdesc): class EnumerationEntry(QtWidgets.QWidget): + modifiedValue = QtCore.pyqtSignal(bool) quickStyleClicked = QtCore.pyqtSignal() def __init__(self, argument): QtWidgets.QWidget.__init__(self) layout = QtWidgets.QHBoxLayout() self.setLayout(layout) - procdesc = argument["desc"] - choices = procdesc["choices"] - if procdesc["quickstyle"]: + self.procdesc = argument["desc"] + choices = self.procdesc["choices"] + if self.procdesc["quickstyle"]: self.btn_group = QtWidgets.QButtonGroup() for i, choice in enumerate(choices): button = QtWidgets.QPushButton(choice) @@ -228,8 +295,19 @@ def submit(index): def update(index): argument["state"] = choices[index] + self.modifiedValue.emit(not self.is_default()) self.combo_box.currentIndexChanged.connect(update) + def is_default(self): + if self.procdesc["quickstyle"]: + return True + value = self.combo_box.currentText() + default_value = self.default_state(self.procdesc) + if value != default_value: + return False + else: + return True + @staticmethod def state_to_value(state): return state @@ -243,27 +321,39 @@ def default_state(procdesc): class NumberEntryInt(QtWidgets.QSpinBox): + modifiedValue = QtCore.pyqtSignal(bool) + def __init__(self, argument): QtWidgets.QSpinBox.__init__(self) disable_scroll_wheel(self) - procdesc = argument["desc"] - self.setSingleStep(procdesc["step"]) - if procdesc["min"] is not None: - self.setMinimum(procdesc["min"]) + self.procdesc = argument["desc"] + self.setSingleStep(self.procdesc["step"]) + if self.procdesc["min"] is not None: + self.setMinimum(self.procdesc["min"]) else: self.setMinimum(-((1 << 31) - 1)) - if procdesc["max"] is not None: - self.setMaximum(procdesc["max"]) + if self.procdesc["max"] is not None: + self.setMaximum(self.procdesc["max"]) else: self.setMaximum((1 << 31) - 1) - if procdesc["unit"]: - self.setSuffix(" " + procdesc["unit"]) + if self.procdesc["unit"]: + self.setSuffix(" " + self.procdesc["unit"]) self.setValue(argument["state"]) + def update(value): argument["state"] = value + self.modifiedValue.emit(not self.is_default()) self.valueChanged.connect(update) + def is_default(self): + value = self.value() + default_value = self.default_state(self.procdesc) + if value != default_value: + return False + else: + return True + @staticmethod def state_to_value(state): return state @@ -288,31 +378,43 @@ def default_state(procdesc): class NumberEntryFloat(ScientificSpinBox): + modifiedValue = QtCore.pyqtSignal(bool) + def __init__(self, argument): ScientificSpinBox.__init__(self) disable_scroll_wheel(self) - procdesc = argument["desc"] - scale = procdesc["scale"] - self.setDecimals(procdesc["precision"]) + self.procdesc = argument["desc"] + scale = self.procdesc["scale"] + self.setDecimals(self.procdesc["precision"]) self.setSigFigs() - self.setSingleStep(procdesc["step"]/scale) + self.setSingleStep(self.procdesc["step"]/scale) self.setRelativeStep() - if procdesc["min"] is not None: - self.setMinimum(procdesc["min"]/scale) + if self.procdesc["min"] is not None: + self.setMinimum(self.procdesc["min"]/scale) else: self.setMinimum(float("-inf")) - if procdesc["max"] is not None: - self.setMaximum(procdesc["max"]/scale) + if self.procdesc["max"] is not None: + self.setMaximum(self.procdesc["max"]/scale) else: self.setMaximum(float("inf")) - if procdesc["unit"]: - self.setSuffix(" " + procdesc["unit"]) + if self.procdesc["unit"]: + self.setSuffix(" " + self.procdesc["unit"]) self.setValue(argument["state"]/scale) + def update(value): argument["state"] = value*scale + self.modifiedValue.emit(not self.is_default()) self.valueChanged.connect(update) + def is_default(self): + value = self.value() + default_value = self.default_state(self.procdesc) + if value*self.procdesc["scale"] != default_value: + return False + else: + return True + @staticmethod def state_to_value(state): return state @@ -326,9 +428,14 @@ def default_state(procdesc): class _NoScan(LayoutWidget): + modifiedValue = QtCore.pyqtSignal(bool) + def __init__(self, procdesc, state): LayoutWidget.__init__(self) + self.procdesc = procdesc + self.state = state + scale = procdesc["scale"] self.value = ScientificSpinBox() disable_scroll_wheel(self.value) @@ -350,8 +457,10 @@ def __init__(self, procdesc, state): self.addWidget(self.value, 0, 1) self.value.setValue(state["value"]/scale) + def update(value): state["value"] = value*scale + self.modified_value_update() self.value.valueChanged.connect(update) self.repetitions = QtWidgets.QSpinBox() @@ -365,14 +474,30 @@ def update(value): def update_repetitions(value): state["repetitions"] = value + self.modified_value_update() self.repetitions.valueChanged.connect(update_repetitions) + def modified_value_update(self): + self.modifiedValue.emit(not self.is_default()) + + def is_default(self): + default = True + default_state = ScanEntry.default_state(self.procdesc)["NoScan"] + for type_ in default_state.keys(): + if self.state[type_] != default_state[type_]: + default = False + break + return default + class _RangeScan(LayoutWidget): + modifiedValue = QtCore.pyqtSignal(bool) + def __init__(self, procdesc, state): LayoutWidget.__init__(self) - + self.procdesc = procdesc scale = procdesc["scale"] + self.state = state def apply_properties(widget): widget.setDecimals(procdesc["precision"]) @@ -428,22 +553,26 @@ def update_start(value): scanner.setStart(value) if start.value() != value: start.setValue(value) + self.modified_value_update() def update_stop(value): state["stop"] = value*scale scanner.setStop(value) if stop.value() != value: stop.setValue(value) + self.modified_value_update() def update_npoints(value): state["npoints"] = value scanner.setNum(value) if npoints.value() != value: npoints.setValue(value) + self.modified_value_update() def update_randomize(value): state["randomize"] = value randomize.setChecked(value) + self.modified_value_update() scanner.startChanged.connect(update_start) scanner.numChanged.connect(update_npoints) @@ -457,11 +586,28 @@ def update_randomize(value): scanner.setStop(state["stop"]/scale) randomize.setChecked(state["randomize"]) + def modified_value_update(self): + self.modifiedValue.emit(not self.is_default()) + + def is_default(self): + default = True + default_state = ScanEntry.default_state(self.procdesc)["RangeScan"] + for type_ in default_state.keys(): + if self.state[type_] != default_state[type_]: + default = False + break + return default + class _CenterScan(LayoutWidget): + modifiedValue = QtCore.pyqtSignal(bool) + def __init__(self, procdesc, state): LayoutWidget.__init__(self) + self.procdesc = procdesc + self.state = state + scale = procdesc["scale"] def apply_properties(widget): @@ -514,26 +660,47 @@ def apply_properties(widget): def update_center(value): state["center"] = value*scale + self.modified_value_update() def update_span(value): state["span"] = value*scale + self.modified_value_update() def update_step(value): state["step"] = value*scale + self.modified_value_update() def update_randomize(value): state["randomize"] = value + self.modified_value_update() center.valueChanged.connect(update_center) span.valueChanged.connect(update_span) step.valueChanged.connect(update_step) randomize.stateChanged.connect(update_randomize) + def modified_value_update(self): + self.modifiedValue.emit(not self.is_default()) + + def is_default(self): + default = True + default_state = ScanEntry.default_state(self.procdesc)["CenterScan"] + for type_ in default_state.keys(): + if self.state[type_] != default_state[type_]: + default = False + break + return default + class _ExplicitScan(LayoutWidget): - def __init__(self, state): + modifiedValue = QtCore.pyqtSignal(bool) + + def __init__(self, procdesc, state): LayoutWidget.__init__(self) + self.procdesc = procdesc + self.state = state + self.value = QtWidgets.QLineEdit() self.addWidget(QtWidgets.QLabel("Sequence:"), 0, 0) self.addWidget(self.value, 0, 1) @@ -543,13 +710,28 @@ def __init__(self, state): self.value.setValidator(QtGui.QRegExpValidator(QtCore.QRegExp(regexp))) self.value.setText(" ".join([str(x) for x in state["sequence"]])) + def update(text): if self.value.hasAcceptableInput(): state["sequence"] = [float(x) for x in text.split()] + self.modified_value_update() self.value.textEdited.connect(update) + def modified_value_update(self): + self.modifiedValue.emit(not self.is_default()) + + def is_default(self): + default = True + default_state = ScanEntry.default_state(self.procdesc)["ExplicitScan"] + for type_ in default_state.keys(): + if self.state[type_] != default_state[type_]: + default = False + break + return default + class ScanEntry(LayoutWidget): + def __init__(self, argument): LayoutWidget.__init__(self) self.argument = argument @@ -558,12 +740,13 @@ def __init__(self, argument): self.addWidget(self.stack, 1, 0, colspan=4) procdesc = argument["desc"] + self.procdesc = procdesc state = argument["state"] self.widgets = OrderedDict() self.widgets["NoScan"] = _NoScan(procdesc, state["NoScan"]) self.widgets["RangeScan"] = _RangeScan(procdesc, state["RangeScan"]) self.widgets["CenterScan"] = _CenterScan(procdesc, state["CenterScan"]) - self.widgets["ExplicitScan"] = _ExplicitScan(state["ExplicitScan"]) + self.widgets["ExplicitScan"] = _ExplicitScan(procdesc, state["ExplicitScan"]) for widget in self.widgets.values(): self.stack.addWidget(widget) @@ -635,7 +818,16 @@ def _scan_type_toggled(self): if button.isChecked(): self.stack.setCurrentWidget(self.widgets[ty]) self.argument["state"]["selected"] = ty + self.widgets[ty].modified_value_update() + break + + def is_default(self): + default = True + for widget in self.widgets.values(): + if not widget.is_default(): + default = False break + return default def procdesc_to_entry(procdesc): diff --git a/artiq/gui/pencil.svg b/artiq/gui/pencil.svg new file mode 100644 index 0000000000..b05e8f193f --- /dev/null +++ b/artiq/gui/pencil.svg @@ -0,0 +1,5 @@ + + + From 15399ba6928e26ecfa1f0d39af453496eb9bad1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sat, 19 Apr 2025 22:09:55 +0200 Subject: [PATCH 2/5] Update the pencil sign accordingly to new default values #5 --- artiq/dashboard/experiments.py | 41 +++++++++++++++++++++++++--------- artiq/dashboard/explorer.py | 8 ++++++- artiq/gui/entries.py | 4 ++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index 35fc29a4fa..784fca73cd 100644 --- a/artiq/dashboard/experiments.py +++ b/artiq/dashboard/experiments.py @@ -58,22 +58,37 @@ def __init__(self, manager, dock, expurl): self.setItemWidget(self.bottom_item, 1, recompute_arguments) def reset_entry(self, key): - asyncio.ensure_future(self._recompute_argument(key)) + asyncio.ensure_future(self._recompute_arguments([key])) + + async def _update_defaults(self): + keys = self._arguments.keys() + await self._recompute_arguments(keys, False) + for key in keys: + widgets = self._arg_to_widgets.get(key, {}) + entry = widgets.get("entry") + modified_value_icon = widgets.get("modified_value_icon") + if entry is not None and modified_value_icon is not None: + if entry.is_default(): + modified_value_icon.setVisible_(False) + else: + modified_value_icon.setVisible_(True) - async def _recompute_argument(self, name): + async def _recompute_arguments(self, keys, update=True): try: expdesc, _ = await self.manager.compute_expdesc(self.expurl) - except: - logger.error("Could not recompute argument '%s' of '%s'", - name, self.expurl, exc_info=True) + except Exception: + logger.error("Could not recompute arguments for keys of '%s'", + self.expurl, exc_info=True) return - argument = self.manager.get_submission_arguments(self.expurl)[name] - procdesc = expdesc["arginfo"][name][0] - state = procdesc_to_entry(procdesc).default_state(procdesc) - argument["desc"] = procdesc - argument["state"] = state - self.update_argument(name, argument) + for name in keys: + argument = self.manager.get_submission_arguments(self.expurl)[name] + procdesc = expdesc["arginfo"][name][0] + state = procdesc_to_entry(procdesc).default_state(procdesc) + argument["desc"] = procdesc + if update: + argument["state"] = state + self.update_argument(name, argument) # Hooks that allow user-supplied argument editors to react to imminent user # actions. Here, we always keep the manager-stored submission arguments @@ -538,6 +553,10 @@ def __init__(self, main_window, dataset_sub, quick_open_shortcut.setContext(QtCore.Qt.ApplicationShortcut) quick_open_shortcut.activated.connect(self.show_quick_open) + def update_open_experiments_defaults(self): + for experiment in self.open_experiments.values(): + experiment.argeditor.update_defaults() + def set_dataset_model(self, model): self.datasets = model diff --git a/artiq/dashboard/explorer.py b/artiq/dashboard/explorer.py index f8c8df1f08..9af09a856a 100644 --- a/artiq/dashboard/explorer.py +++ b/artiq/dashboard/explorer.py @@ -249,7 +249,13 @@ def __init__(self, exp_manager, d_shortcuts, self.el) def scan_repository(): - asyncio.ensure_future(experiment_db_ctl.scan_repository_async()) + future = asyncio.ensure_future( + experiment_db_ctl.scan_repository_async()) + + def on_complete(future): + self.exp_manager.update_open_experiments_defaults() + future.add_done_callback(on_complete) + scan_repository_action.triggered.connect(scan_repository) self.el.addAction(scan_repository_action) diff --git a/artiq/gui/entries.py b/artiq/gui/entries.py index 379f86c065..339c0eb75c 100644 --- a/artiq/gui/entries.py +++ b/artiq/gui/entries.py @@ -1,3 +1,4 @@ +import asyncio import logging import os from collections import OrderedDict @@ -134,6 +135,9 @@ def set_argument(self, key, argument): tool_buttons.addWidget(disable_other_scans, 2) self.setItemWidget(widget_item, 2, tool_buttons) + def update_defaults(self): + asyncio.ensure_future(self._update_defaults()) + def _get_group(self, key): if key in self._groups: return self._groups[key] From add67a25dcc6520c2c07286221bcb281fb53445e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Mon, 24 Mar 2025 00:38:49 +0100 Subject: [PATCH 3/5] Notify about the change in experiment default values #5 --- artiq/master/experiments.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/artiq/master/experiments.py b/artiq/master/experiments.py index 935872fb09..ce7dee126f 100644 --- a/artiq/master/experiments.py +++ b/artiq/master/experiments.py @@ -119,6 +119,8 @@ async def scan_repository(self, new_cur_rev=None): new_explist = await _RepoScanner(self.worker_handlers).scan( wd, self.experiment_subdir) logger.info("repository scan took %d seconds", time.monotonic()-t1) + if hash(str(self.explist.raw_view)) != hash(str(new_explist)): + logger.warning("Experiments' settings have changed") update_from_dict(self.explist, new_explist) finally: self._scanning = False From 8ce3e351b72a089a3ccc91e9f889c2acfbac028e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Sun, 23 Mar 2025 23:53:30 +0100 Subject: [PATCH 4/5] Handle the case when default argument value is missing #5 --- artiq/dashboard/experiments.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/artiq/dashboard/experiments.py b/artiq/dashboard/experiments.py index 784fca73cd..985356216d 100644 --- a/artiq/dashboard/experiments.py +++ b/artiq/dashboard/experiments.py @@ -83,7 +83,11 @@ async def _recompute_arguments(self, keys, update=True): for name in keys: argument = self.manager.get_submission_arguments(self.expurl)[name] - procdesc = expdesc["arginfo"][name][0] + try: + procdesc = expdesc["arginfo"][name][0] + except KeyError: + logging.warning(f"Default value for {name} is missing") + continue state = procdesc_to_entry(procdesc).default_state(procdesc) argument["desc"] = procdesc if update: From fb8c1000304f044995a40528f6be60e908f8b5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Malczak?= Date: Mon, 24 Mar 2025 00:15:13 +0100 Subject: [PATCH 5/5] Notify about the change in experiments layout #5 --- artiq/master/experiments.py | 4 +++- artiq/tools.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/artiq/master/experiments.py b/artiq/master/experiments.py index ce7dee126f..f7c529a212 100644 --- a/artiq/master/experiments.py +++ b/artiq/master/experiments.py @@ -9,7 +9,7 @@ from artiq.master.worker import (Worker, WorkerInternalException, log_worker_exception) -from artiq.tools import get_windows_drives, exc_to_warning +from artiq.tools import get_windows_drives, exc_to_warning, compare_keys logger = logging.getLogger(__name__) @@ -121,6 +121,8 @@ async def scan_repository(self, new_cur_rev=None): logger.info("repository scan took %d seconds", time.monotonic()-t1) if hash(str(self.explist.raw_view)) != hash(str(new_explist)): logger.warning("Experiments' settings have changed") + if not compare_keys(self.explist.raw_view, new_explist): + logger.warning("Experiments' layout has changed") update_from_dict(self.explist, new_explist) finally: self._scanning = False diff --git a/artiq/tools.py b/artiq/tools.py index 46c0ce2f71..ab09611820 100644 --- a/artiq/tools.py +++ b/artiq/tools.py @@ -198,3 +198,15 @@ def get_user_config_dir(): dir = user_config_dir("artiq", "m-labs", major) os.makedirs(dir, exist_ok=True) return dir + + +def compare_keys(d1, d2): + if set(d1.keys()) != set(d2.keys()): + return False + for key in d1: + if isinstance(d1[key], dict) and isinstance(d2[key], dict): + if not compare_keys(d1[key], d2[key]): + return False + elif isinstance(d1[key], dict) or isinstance(d2[key], dict): + return False + return True