diff --git a/grum/cli.py b/grum/cli.py index e1df4ae..fb2d9b9 100644 --- a/grum/cli.py +++ b/grum/cli.py @@ -25,13 +25,14 @@ def handle_clargs(): DESC = "grum - GUI for Remote Unified Monitoring" parser = argparse.ArgumentParser(description=DESC, formatter_class=argparse.ArgumentDefaultsHelpFormatter) - parser.add_argument("-H", "--host", default="localhost", help="Server host name") - parser.add_argument("-P", "--port", default=8000, type=int, help="Server port number") + parser.add_argument("-H", "--host", default="localhost", help="RPC server host name") + parser.add_argument("-P", "--port", default=8000, type=int, help="RPC server port number") + parser.add_argument("-o", "--offline", action="store_true", help="offline mode (do not run RPC server)") - parser.add_argument("-w", "--window-mode", default="multi", choices=MDIWindowMode.values(), type=unambiguous_window_mode, help="Set the initial window mode") + parser.add_argument("-w", "--window-mode", default="multi", choices=MDIWindowMode.values(), type=unambiguous_window_mode, help="set the initial window mode") - parser.add_argument("-e", "--examples", dest="add_examples", action="store_true", help="Add example data") - parser.add_argument("--no-theme", action="store_true", help="Disable theming") + parser.add_argument("-e", "--examples", dest="add_examples", action="store_true", help="add example data") + parser.add_argument("--no-theme", action="store_true", help="disable theming") return parser.parse_args().__dict__ diff --git a/grum/dictlist/dictlist.py b/grum/dictlist/dictlist.py index aa7c706..b9b8d53 100644 --- a/grum/dictlist/dictlist.py +++ b/grum/dictlist/dictlist.py @@ -1,4 +1,7 @@ -from PyQt5.QtWidgets import QWidget, QLineEdit, QVBoxLayout +import fnmatch +import re + +from PyQt5.QtWidgets import QWidget, QVBoxLayout from .dictlistwidget import DictListWidget from .searchbox import SearchBox @@ -31,11 +34,26 @@ class DictList(QWidget): def hide_not_matching(self, pattern): - pattern = pattern.casefold() + g = Globber(pattern) for name, itm in self.lst.items.items(): - name = name.casefold() - state = (pattern not in name) - itm.setHidden(state) + state = g.match(name) + itm.setHidden(not state) + + + +class Globber: + + def __init__(self, pattern): + pattern = pattern.casefold() + pattern = "*" + pattern + "*" + regex = fnmatch.translate(pattern) + self.pattern = re.compile(regex) + + def match(self, string): + string = string.casefold() + matches = self.pattern.match(string) + state = bool(matches) + return state diff --git a/grum/dictlist/dictlistitem.py b/grum/dictlist/dictlistitem.py index 982e24e..e736e1c 100644 --- a/grum/dictlist/dictlistitem.py +++ b/grum/dictlist/dictlistitem.py @@ -3,6 +3,7 @@ from PyQt5.QtWidgets import QListWidgetItem from PyQt5.QtGui import QIcon, QPixmap, QPainter from ..theme import DOT_PEN, DOT_BRUSH, DOT_SIZE +from .timestamps import Timestamps class DictListItem(QListWidgetItem): @@ -11,9 +12,25 @@ class DictListItem(QListWidgetItem): super().__init__(key, *args, **kwargs) self.key = key self.value = value + self.timestamps = Timestamps() self.set_alarm(False) + _sort_key = None + + @classmethod # need to update the class so that all instances are always consistent + def set_sort_key(cls, sk): + cls._sort_key = staticmethod(sk) # need to attach as staticmethod, otherwise self is inserted as first argument + + def __lt__(self, other): + assert self._sort_key == other._sort_key + sk = self._sort_key + if sk: + return sk(self) < sk(other) + else: + return super().__lt__(other) + + def set_alarm(self, state): self.set_bold(state) self.set_dot(state) diff --git a/grum/dictlist/dictlistwidget.py b/grum/dictlist/dictlistwidget.py index dabc018..a27f14d 100644 --- a/grum/dictlist/dictlistwidget.py +++ b/grum/dictlist/dictlistwidget.py @@ -14,8 +14,12 @@ class DictListWidget(QListWidget): self.setSelectionMode(QListWidget.ExtendedSelection) self.items = {} self._add_menu() + shortcut(self, "Del", self.delete_selected, context=Qt.WidgetShortcut) + self.nkeep = None + self.model().rowsInserted.connect(self.on_evict) + def _add_menu(self): self.menu = menu = RClickMenu(self) @@ -72,4 +76,69 @@ class DictListWidget(QListWidget): i.set_alarm(state) + def set_sort_key(self, sk, reverse=True): + DictListItem.set_sort_key(sk) + + if sk is None: + self.setSortingEnabled(False) + else: + self.setSortingEnabled(True) + order = Qt.DescendingOrder if reverse else Qt.AscendingOrder + self.sortItems(order) + + + def enable_sort_by_insertion_order(self): + # map order in dict to indices + mapping = enumerate(self.items.values()) + mapping = {item.text(): index for index, item in mapping} + + def unsort(x): + return mapping[x.text()] + + self.set_sort_key(unsort) + self.disable_sorting() + + + def enable_sort_by_text(self): + self.set_sort_key(lambda x: x.text(), reverse=False) + + + def enable_sort_by_timestamp(self): + # fall back to name for identical timestamps + self.set_sort_key(lambda x: (x.timestamps.max(), x.text())) + + + def disable_sorting(self): + self.set_sort_key(None) + + + def set_nkeep(self, n): + self.nkeep = n + self.on_evict() + + + def on_evict(self): + if self.nkeep: + self.evict(self.nkeep) + + + def evict(self, nkeep): + items = self.items.values() + + # map order in dict to indices + mapping = enumerate(items) + mapping = {item.text(): index for index, item in mapping} + + def sk(x): + # fall back to insertion order for identical timestamps + return (x.timestamps.max(), mapping[x.text()]) + + items = sorted(items, key=sk, reverse=True) + items_to_evict = items[nkeep:] + + for i in items_to_evict: + self.items.pop(i.key) + self.deleteItem(i) + + diff --git a/grum/dictlist/searchbox.py b/grum/dictlist/searchbox.py index 76a8619..fcfd742 100644 --- a/grum/dictlist/searchbox.py +++ b/grum/dictlist/searchbox.py @@ -47,7 +47,7 @@ class SearchBox(QWidget): class SquareButton(QPushButton): - def resizeEvent(self, e): + def resizeEvent(self, _event): height = self.height() self.setMinimumWidth(height) diff --git a/grum/exampledata.py b/grum/exampledata.py index f6bb3b4..8eb609c 100644 --- a/grum/exampledata.py +++ b/grum/exampledata.py @@ -20,15 +20,15 @@ for i in range(20): exampledata = {} -for k, v in exampledata_raw.items(): - pd = PlotDescription( - k, -# title=k, +for name, (xs, ys) in exampledata_raw.items(): + exampledata[name] = PlotDescription( + name, +# title=name, xlabel="x", - ylabel="y" + ylabel="y", + xs=xs, + ys=ys ) - pd.xs, pd.ys = v - exampledata[k] = pd diff --git a/grum/io.py b/grum/io.py index 5b67a48..950a804 100644 --- a/grum/io.py +++ b/grum/io.py @@ -31,7 +31,7 @@ def read_dict(fn): data = node[()] if isinstance(data, bytes): data = data.decode("utf-8") - res[node.name] = data + res[name] = data with h5py.File(fn, "r") as f: f.visititems(visit) @@ -44,7 +44,7 @@ def unflatten_dict(d, sep="/"): for k, v in d.items(): current = res levels = k.split(sep) - for l in levels[1:-1]: + for l in levels[:-1]: if l not in current: current[l] = {} current = current[l] diff --git a/grum/mainwin.py b/grum/mainwin.py index cd7ed77..b4a3651 100644 --- a/grum/mainwin.py +++ b/grum/mainwin.py @@ -18,8 +18,12 @@ class MainWindow(QMainWindow): sig_make_new_plot = pyqtSignal(str, PlotDescription) - def __init__(self, *args, title="grum", host="localhost", port=8000, add_examples=False, window_mode=MDIWindowMode.MULTI, **kwargs): + def __init__(self, *args, title="grum", host="localhost", port=8000, offline=False, add_examples=False, window_mode=MDIWindowMode.MULTI, **kwargs): super().__init__(*args, **kwargs) + + if offline: + title = f"{title} (offline)" + self.setWindowTitle(title) self.setWindowIcon(assets.icon()) @@ -39,6 +43,19 @@ class MainWindow(QMainWindow): lst_menu.addSeparator() lst_menu.addAction("Mark selected as seen", self.on_mark_selected_as_seen) lst_menu.addAction("Mark selected as not seen", self.on_mark_selected_as_not_seen) + lst_menu.addSeparator() + sort_group = lst_menu.addGroup() + sort_group.addCheckbox("Sort by insertion order", triggered=self.on_sort_by_insertion_order, state=True) + sort_group.addCheckbox("Sort by name", triggered=self.on_sort_by_name) + sort_group.addCheckbox("Sort by timestamp", triggered=self.on_sort_by_timestamp) + sort_group.addCheckbox("Sorting disabled", triggered=self.on_sorting_disabled) + + #TODO: clean up + def on_item_about_to_be_moved(): + sort_group.checkboxes["Sorting disabled"].setChecked(True) + self.on_sorting_disabled() + + self.lst.model().rowsAboutToBeMoved.connect(on_item_about_to_be_moved) shortcut(self, "Ctrl+P", self.on_plot_selected) @@ -52,6 +69,8 @@ class MainWindow(QMainWindow): self.menu_settings = menu = BarMenu(bar, "&Settings") menu.addCheckbox("Open new plots", state=True) + menu.addSeparator() + menu.addEntrybox("Limit number of entries", placeholder="Maximum number of entries", triggered=lst.set_nkeep) self.mdi = mdi = MDIArea(bar, window_mode=window_mode) @@ -62,10 +81,11 @@ class MainWindow(QMainWindow): self.setCentralWidget(splitter) - self.rst = rst = RPCServerThread(host, port, doc_title_suffix=title) - rst.start() - rst.server.register_function(self.new_plot) - rst.server.register_function(self.append_data) + if not offline: + self.rst = rst = RPCServerThread(host, port, doc_title_suffix=title) + rst.start() + rst.server.register_function(self.new_plot) + rst.server.register_function(self.append_data) self.sig_make_new_plot.connect(self.on_make_new_plot) @@ -78,12 +98,21 @@ class MainWindow(QMainWindow): # Remote API calls def new_plot(self, name, cfg): + """ + Create a new plot using the configuration dict . + The configuration is forwarded to the constructor of PlotDescription. + Allowed keys are: title, xlabel, ylabel, xs, ys. + """ desc = self.add_new_desc_to_list(name, cfg) if self.menu_settings.checkboxes["Open new plots"].isChecked(): if not self.mdi.findSubWindow(name): self.sig_make_new_plot.emit(name, desc) def append_data(self, name, point): + """ + Append a new data point to the (existing) plot . + The point is forwarded to the append method of PlotDescription. + """ item = self.lst.get(name) desc = item.value desc.append(point) @@ -93,6 +122,7 @@ class MainWindow(QMainWindow): plot = sub.plots[name] plot.setData(*desc.data) alarm = False + item.timestamps.modification.update() item.set_alarm(alarm) @@ -114,6 +144,7 @@ class MainWindow(QMainWindow): else: self.plot_multiple_items(selected) + def on_mark_selected_as_seen(self): self.lst.set_alarm_for_selected(False) @@ -121,6 +152,19 @@ class MainWindow(QMainWindow): self.lst.set_alarm_for_selected(True) + def on_sort_by_insertion_order(self): + self.lst.enable_sort_by_insertion_order() + + def on_sort_by_name(self): + self.lst.enable_sort_by_text() + + def on_sort_by_timestamp(self): + self.lst.enable_sort_by_timestamp() + + def on_sorting_disabled(self): + self.lst.disable_sorting() + + def on_file_open(self): fns = open_h5_files_dialog(self) if not fns: @@ -153,12 +197,14 @@ class MainWindow(QMainWindow): return desc def plot_single_item(self, item): + item.timestamps.access.update() item.set_alarm(False) name, desc = item.key, item.value self.activate_or_make_subwin(MDISubPlot, name, desc) def plot_multiple_items(self, items): for i in items: + i.timestamps.access.update() i.set_alarm(False) descs = {i.key: i.value for i in items} names = descs.keys() diff --git a/grum/mdi/mdiarea.py b/grum/mdi/mdiarea.py index c7527ee..a728c78 100644 --- a/grum/mdi/mdiarea.py +++ b/grum/mdi/mdiarea.py @@ -20,7 +20,7 @@ class MDIWindowMode(str, enum.Enum): class MDIArea(QMdiArea): - def __init__(self, bar, window_mode=MDIWindowMode.MULTI, *args, **kwargs): + def __init__(self, bar, *args, window_mode=MDIWindowMode.MULTI, **kwargs): super().__init__(*args, **kwargs) self.logo = assets.logo() self.setTabsClosable(True) @@ -67,6 +67,7 @@ class MDIArea(QMdiArea): self.menu.checkboxes["Multiple windows"].setChecked(True) self.enable_subwindow_view() for sub in self.subWindowList(): + sub.restore() sub.frame_on() def enable_single_window_mode(self): @@ -75,7 +76,7 @@ class MDIArea(QMdiArea): self.closeInactiveSubWindows() active = self.activeSubWindow() if active: - active.showMaximized() + active.maximize() active.frame_off() def enable_tabbed_mode(self): @@ -104,7 +105,7 @@ class MDIArea(QMdiArea): def add_single(self, sub): self.closeAllSubWindows() self.addSubWindow(sub) - sub.showMaximized() + sub.maximize() sub.frame_off() diff --git a/grum/mdi/mdisubplot.py b/grum/mdi/mdisubplot.py index 1c77d0f..53bc67e 100644 --- a/grum/mdi/mdisubplot.py +++ b/grum/mdi/mdisubplot.py @@ -3,30 +3,48 @@ from .mdisubwin import MDISubWindow from ..theme import pg_plot_style, pg_plot_style_cycler, pg_legend_style -class MDISubPlot(MDISubWindow): +class MDISubPlotBase(MDISubWindow): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.pw = pw = pg.PlotWidget() + self.setWidget(pw) + + # connect to plot mouse-over event + pw.scene().sigMouseMoved.connect(self.on_hover) + + + def on_hover(self, event): + coord = self.pw.plotItem.vb.mapSceneToView(event) + x = coord.x() + y = coord.y() + x = round(x, 3) + y = round(y, 3) + self.setToolTip(f"x = {x}\ny = {y}") + + + +class MDISubPlot(MDISubPlotBase): def __init__(self, name, desc, *args, **kwargs): super().__init__(name, *args, **kwargs) - pw = pg.PlotWidget() - self.setWidget(pw) style = pg_plot_style() - plot = desc.make_plot(pw, style) + plot = desc.make_plot(self.pw, style) self.plots = {name: plot} -class MDISubMultiPlot(MDISubWindow): +class MDISubMultiPlot(MDISubPlotBase): def __init__(self, name, descs, *args, **kwargs): super().__init__(name, *args, **kwargs) - pw = pg.PlotWidget() - self.setWidget(pw) ls = pg_legend_style() psc = pg_plot_style_cycler() + pw = self.pw pw.addLegend(**ls) self.plots = {name: desc.make_plot(pw, style) for (name, desc), style in zip(descs.items(), psc)} diff --git a/grum/mdi/mdisubwin.py b/grum/mdi/mdisubwin.py index 66728e1..64c45a1 100644 --- a/grum/mdi/mdisubwin.py +++ b/grum/mdi/mdisubwin.py @@ -14,12 +14,27 @@ class MDISubWindow(QMdiSubWindow): # without this, the SubWindow is not removed from the subWindowList self.setAttribute(Qt.WA_DeleteOnClose) + self._previous_state = None + + + def maximize(self): + self._previous_state = state = self.windowState() + self.setWindowState(state | Qt.WindowMaximized) + + def restore(self): + if self._previous_state: + self.setWindowState(self._previous_state) + def frame_on(self): - self.setWindowFlag(Qt.FramelessWindowHint, False) + self.hide() + self.setWindowFlags(Qt.SubWindow) + self.show() def frame_off(self): - self.setWindowFlag(Qt.FramelessWindowHint, True) + self.hide() + self.setWindowFlags(Qt.CustomizeWindowHint | Qt.WindowTitleHint) + self.show() diff --git a/grum/menus/menubase.py b/grum/menus/menubase.py index 0642c86..f84298a 100644 --- a/grum/menus/menubase.py +++ b/grum/menus/menubase.py @@ -1,5 +1,5 @@ -from PyQt5.QtGui import QKeySequence -from PyQt5.QtWidgets import QAction, QActionGroup +from PyQt5.QtGui import QKeySequence, QIntValidator +from PyQt5.QtWidgets import QAction, QActionGroup, QWidgetAction, QLineEdit class MenuBase: @@ -31,6 +31,48 @@ class MenuBase: self.checkboxes[name] = action return action + + def addEntrybox(self, name, placeholder=None, state=False, triggered=None): + cb = self.addCheckbox(name, state=state) + + edit = QLineEdit(self.qmenu) + edit.setValidator(QIntValidator()) #TODO: make optional + edit.setEnabled(state) + if placeholder: + edit.setPlaceholderText(placeholder) + edit.setContentsMargins(8, 5, 8, 5) # mimic margins of other actions TODO: this probably depends on the theme + + @cb.toggled.connect + def propagate_state_and_keep_menu_open_and_focus_edit(checked): + edit.setEnabled(checked) + if checked: + self.qmenu.show() + self.qmenu.setActiveAction(cb) + edit.setFocus() + + @edit.returnPressed.connect + def close_menu(): + self.qmenu.close() + + if triggered: + @cb.triggered.connect + def confirm_disabled(checked): + if not checked: + triggered(None) + + if triggered: + @edit.returnPressed.connect + def confirm_value(): + value = edit.text() + value = int(value) + triggered(value) + + action = QWidgetAction(self.qmenu) + action.setDefaultWidget(edit) + self.qmenu.addAction(action) + return action + + def addSeparator(self): self.qmenu.addSeparator() diff --git a/grum/rpc/rpcserver.py b/grum/rpc/rpcserver.py index f8ac55d..2e3496b 100644 --- a/grum/rpc/rpcserver.py +++ b/grum/rpc/rpcserver.py @@ -4,7 +4,7 @@ from inspect import getdoc, signature class RPCServer(xrs.DocXMLRPCServer): - def __init__(self, host, port, doc_title_suffix="", *args, **kwargs): + def __init__(self, host, port, *args, doc_title_suffix="", **kwargs): addr = (host, port) kwargs.setdefault("allow_none", True) super().__init__(addr, *args, **kwargs) diff --git a/grum/shortcut.py b/grum/shortcut.py index 6845b4c..ec71c5a 100644 --- a/grum/shortcut.py +++ b/grum/shortcut.py @@ -4,9 +4,9 @@ from PyQt5.QtWidgets import QShortcut def shortcut(parent, key_sequence, triggered, **kwargs): key_sequence = QKeySequence(key_sequence) - shortcut = QShortcut(key_sequence, parent, **kwargs) - shortcut.activated.connect(triggered) - return shortcut + sc = QShortcut(key_sequence, parent, **kwargs) + sc.activated.connect(triggered) + return sc diff --git a/grum/theme.py b/grum/theme.py index fe4dd90..002b07f 100644 --- a/grum/theme.py +++ b/grum/theme.py @@ -27,7 +27,7 @@ DOT_PEN = GREY5 DOT_BRUSH = HIGHLIGHT DOT_SIZE = 8 -LINE_COLORS = [ +LINE_COLORS = ( C.BLUE, C.BASE_GREEN, C.RED, @@ -38,7 +38,7 @@ LINE_COLORS = [ C.COOL3, C.BROWN1, C.BASE_ORANGE, -] +) diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..a13dfdb --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +cd $(dirname $0) +cd .. + +export QT_QPA_PLATFORM=offscreen + +coverage run --source=./grum/ -m pytest ./tests/ +echo +coverage report + + diff --git a/tests/test_mdiarea.py b/tests/test_mdiarea.py index 4f30954..26fd6d0 100644 --- a/tests/test_mdiarea.py +++ b/tests/test_mdiarea.py @@ -143,14 +143,14 @@ class TestMDIArea: mdi.closeAllSubWindows = mock.MagicMock() sine_item = self.mw.lst.lst.get("sine") sub = MDISubPlot("sine", sine_item.value) - sub.showMaximized = mock.MagicMock() + sub.maximize = mock.MagicMock() sub.frame_off = mock.MagicMock() mdi.add_single(sub) mdi.closeAllSubWindows.assert_called_once() mdi.addSubWindow.assert_called_once_with(sub) - sub.showMaximized.assert_called_once() + sub.maximize.assert_called_once() sub.frame_off.assert_called_once()