30 Commits

Author SHA1 Message Date
stalbe_j
ca2caad46c new tests from before 2023-04-13 14:49:04 +02:00
stalbe_j
b13b91e55b Merge branch 'tests' of gitlab.psi.ch:augustin_s/grum into tests 2023-03-14 14:53:45 +01:00
stalbe_j
7780281e0c Merge branch 'master' of gitlab.psi.ch:augustin_s/grum into tests 2023-02-06 13:34:23 +01:00
76e3a04d98 added addEntrybox for max #entries 2023-02-04 18:54:36 +01:00
c620f17344 added addEntrybox 2023-02-04 18:53:53 +01:00
495a9a43bc added eviction, attached it to list changes signal 2023-02-04 18:52:49 +01:00
ab1d0d524e correctly set descending order (except for "sort by text") 2023-02-03 00:59:52 +01:00
cf4fbad5e7 update timestamps; added "Sort by timestamp" 2023-02-03 00:56:07 +01:00
5ea830bf2b attached timestamps object 2023-02-03 00:55:08 +01:00
87ae26247d attached timestamps object 2023-02-03 00:54:00 +01:00
23afec2aa4 fill xs and ys as argument 2023-02-03 00:53:19 +01:00
e4ada964e3 (trying to) disable sorting if item manually moved 2023-02-02 17:27:20 +01:00
9b50ade340 introduced "Sort by insertion order" (the default) / "Sorting disabled" keeps current order 2023-02-02 16:48:18 +01:00
26c338c54e added a comment 2023-02-02 14:59:51 +01:00
721f2a864a added docstrings for API methods 2023-02-02 14:59:42 +01:00
4c533489f6 added wildcard (*?) support 2023-02-02 13:28:52 +01:00
c7c17ef221 change sort key on the class (not the instances) 2023-02-02 11:59:05 +01:00
3ba74a1a1e added a tests run script 2023-02-01 16:17:58 +01:00
9e6896c30f fixed position of *args 2023-02-01 16:10:47 +01:00
829c9e2ded use different name inside function 2023-02-01 16:10:24 +01:00
9a66433a3e made LINE_COLORS tuple instead of list 2023-02-01 16:09:59 +01:00
9db8d5b87e cleanup 2023-02-01 15:31:31 +01:00
b380d840fc attach sorting options to menu 2023-02-01 15:26:38 +01:00
31cbe8ac33 added possibility to enabling/disabling sorting for the list 2023-02-01 15:26:17 +01:00
b02d66b95f added possibility for having a sort key 2023-02-01 15:25:37 +01:00
660e8208ca added offline mode (does not start the RPC server) 2023-01-29 16:32:41 +01:00
f491c71462 adjusted test 2023-01-29 14:19:34 +01:00
16c7b4f71c added maximize/restore to MDISubWindow, changed frame_on/frame_off from FramelessWindowHint to custom hint 2023-01-29 14:05:18 +01:00
aeaabd028f connect tooltip display with xy coords to plot mouse-over event 2023-01-27 18:50:34 +01:00
4e3d8b9da6 introduced MDISubPlotBase 2023-01-27 18:34:17 +01:00
19 changed files with 326 additions and 50 deletions

View File

@@ -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__

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -47,7 +47,7 @@ class SearchBox(QWidget):
class SquareButton(QPushButton):
def resizeEvent(self, e):
def resizeEvent(self, _event):
height = self.height()
self.setMinimumWidth(height)

View File

@@ -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

View File

@@ -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]

View File

@@ -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 <name> using the configuration dict <cfg>.
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 <point> to the (existing) plot <name>.
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()

View File

@@ -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()

View File

@@ -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)}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
]
)

12
tests/run.sh Executable file
View File

@@ -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

View File

@@ -0,0 +1,27 @@
from grum.dictlist.dictlistwidget import DictListWidget
from grum.menus.rclickmenu import RClickMenu
class DictListWidgetMock(DictListWidget):
def __init__(self) -> None:
self.items = {}
self.nkeep = None
def get_DictListWidgetMock():
return DictListWidgetMock()
# def test_defaults():
# dlw = get_DictListWidgetMock()
# assert dlw.items == {}
# assert dlw.nkeep == None
# def test_add_menu():
# dlw = get_DictListWidgetMock()
# dlw._add_menu()
# assert dlw.menu == RClickMenu(dlw)

View File

@@ -21,7 +21,6 @@ from grum.rpc import RPCServerThread
class TestMainWin:
def setup_method(self):
print("setup")
self.app = QApplication(sys.argv)
theme.apply(self.app)
# ctrl_c.setup(self.app)
@@ -30,10 +29,8 @@ class TestMainWin:
def teardown_method(self):
print("teardown")
self.mw.rst.wait_for_stop()
self.app.quit()
print("app quit called")
del self.mw
del self.app
@@ -167,6 +164,19 @@ class TestMainWin:
mw.on_plot_selected()
mw.plot_multiple_items.assert_called_once_with([sine_item, cosine_item])
def test_on_sort_by_name(self):
mw = self.mw
mw.lst = DictList()
mw.new_plot("bb", {})
mw.new_plot("dd", {})
mw.new_plot("aa", {})
mw.new_plot("cc", {})
assert mw.lst.lst.items == 111
assert [key for key in mw.lst.lst.items.keys()] == ['bb', 'dd', 'aa', 'cc']
mw.on_sort_by_name()
assert mw.lst.lst.items == 111
def test_plot_single_item(self):
mw = self.mw

View File

@@ -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()