Merge branch 'master' into 'tests'

# Conflicts:
#   grum/mainwin.py
This commit is contained in:
stalbe_j
2023-01-24 09:50:57 +00:00
9 changed files with 244 additions and 23 deletions

View File

@ -1,3 +1,35 @@
# grum
<img src="https://gitlab.psi.ch/augustin_s/grum/-/wikis/uploads/e4cd2be847d26bb7ac7100080edbccce/screenshot.png" width="50%" />
<img src="https://gitlab.psi.ch/augustin_s/grum/-/wikis/uploads/1a259a1d74e7b79e0230e7bbad3b1284/screenshot2.png" width="50%" />
## Overview
grum is a plotting GUI (using [pyqt5](https://www.riverbankcomputing.com/software/pyqt/)/[pyqtgraph](https://www.pyqtgraph.org/)) with an embedded RPC server (using [xmlrpc](https://docs.python.org/3/library/xmlrpc.html)).
In the GUI, a list/history can be used to open individual or overlay several plots.
Via the RPC server, new plots can be created and new data appended to existing plots.
## GUI
- Selecting items in the list allows the usual shortcuts (ctrl-/shift-click, `ctrl+a`, etc.).
- The selected items can be plotted using `ctrl+p`, with more options in the right-click menu.
- The list of plots is searchable via the bottom left search box or by pressing `ctrl+f`.
## API
### User functions
- `new_plot(name, cfg)`
Creates a new plot named `name` in the grum list. The configuration dict `cfg` is used as arguments for the constructor of [`PlotDescription`](https://gitlab.psi.ch/augustin_s/grum/-/blob/master/grum/plotdesc.py#L4).
- `append_data(name, point)`
Append data point to the plot named `name`. The new `point` is forwarded to [`PlotDescription.append()`](https://gitlab.psi.ch/augustin_s/grum/-/blob/master/grum/plotdesc.py#L18).
### Utility functions
- `utils.ping()`
- `utils.help()`
- `utils.info()`

View File

@ -1,18 +1,44 @@
import argparse
import sys
from PyQt5.QtWidgets import QApplication
from . import ctrl_c, theme
from .mainwin import MainWindow
from .mdi import MDIWindowMode
def main():
app = QApplication(sys.argv)
theme.apply(app)
ctrl_c.setup(app)
mw = MainWindow()
clargs = handle_clargs()
mw = MainWindow(**clargs)
mw.show()
sys.exit(app.exec())
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("-e", "--examples", dest="add_examples", action="store_true", help="Add example data")
parser.add_argument("-w", "--window-mode", default="multi", choices=MDIWindowMode.values(), type=unambiguous_window_mode, help="Set the initial window mode")
return parser.parse_args().__dict__
def unambiguous_window_mode(arg):
cfarg = arg.casefold()
values = MDIWindowMode.values()
matches = [i for i in values if i.casefold().startswith(cfarg)]
if len(matches) == 1:
return matches[0]
return arg

View File

@ -0,0 +1,49 @@
import functools
from datetime import datetime
DEFAULT_NAMES = (
"creation",
"access",
"modification"
)
class Timestamps:
def __init__(self, names=DEFAULT_NAMES):
dt = datetime.now() # make sure all times are identical at the start
self.times = {n: Time(dt) for n in names}
self.__dict__.update(self.times)
def max(self):
return max(self.times.values())
def min(self):
return min(self.times.values())
def __repr__(self):
return repr(self.times)
@functools.total_ordering
class Time:
def __init__(self, dt=None):
self.dt = dt or datetime.now()
def update(self):
self.dt = datetime.now()
def __repr__(self):
return repr(self.dt)
def __eq__(self, other):
return (self.dt == other.dt)
def __lt__(self, other):
return (self.dt < other.dt)

View File

@ -4,27 +4,33 @@ from PyQt5.QtWidgets import QMainWindow, QSplitter
from . import assets
from .dictlist import DictList
from .exampledata import exampledata
from .mdi import MDIArea, MDISubMultiPlot, MDISubPlot
from .mdi import MDIArea, MDISubMultiPlot, MDISubPlot, MDIWindowMode
from .menus import BarMenu
from .plotdesc import PlotDescription
from .rpc import RPCServerThread
from .shortcut import shortcut
from .webview import WebView
class MainWindow(QMainWindow):
sig_make_new_plot = pyqtSignal(str, PlotDescription)
def __init__(self, *args, title="grum", host="localhost", port=8000, **kwargs):
def __init__(self, *args, title="grum", host="localhost", port=8000, add_examples=False, window_mode=MDIWindowMode.MULTI, **kwargs):
super().__init__(*args, **kwargs)
self.setWindowTitle(title)
self.setWindowIcon(assets.icon())
url = f"http://{host}:{port}/"
self.webdoc = WebView(url, title=title)
self.lst = lst = DictList()
lst.update(exampledata)
lst.setAlternatingRowColors(True)
lst.itemDoubleClicked.connect(self.on_dclick_list_item)
if add_examples:
lst.update(exampledata)
lst_menu = lst.lst.menu
lst_menu.addSeparator()
lst_menu.addAction("Plot selected", self.on_plot_selected)
@ -36,7 +42,7 @@ class MainWindow(QMainWindow):
self.menu_settings = menu = BarMenu(bar, "&Settings")
menu.addCheckbox("Open new plots", state=True)
self.mdi = mdi = MDIArea(bar)
self.mdi = mdi = MDIArea(bar, window_mode=window_mode)
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(lst)
@ -45,14 +51,20 @@ class MainWindow(QMainWindow):
self.setCentralWidget(splitter)
self.rst = RPCServerThread(host, port)
self.rst.start()
self.rst.server.register_function(self.new_plot)
self.rst.server.register_function(self.append_data)
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)
def keyPressEvent(self, event):
if event.key() == Qt.Key_F1:
self.webdoc.show()
# Remote API calls
def new_plot(self, name, cfg):

View File

@ -1,5 +1,5 @@
from .mdiarea import MDIArea
from .mdiarea import MDIArea, MDIWindowMode
from .mdisubplot import MDISubPlot, MDISubMultiPlot

View File

@ -1,4 +1,5 @@
from PyQt5.QtWidgets import QMdiArea, QAction
import enum
from PyQt5.QtWidgets import QMdiArea
from PyQt5.QtGui import QPainter
from .. import assets
@ -6,14 +7,26 @@ from ..theme import MDI_BKG
from ..menus import BarMenu
class MDIWindowMode(str, enum.Enum):
MULTI = "multi"
SINGLE = "single"
TABS = "tabs"
@classmethod
def values(cls):
return tuple(i.value for i in cls)
class MDIArea(QMdiArea):
def __init__(self, bar, *args, **kwargs):
def __init__(self, bar, window_mode=MDIWindowMode.MULTI, *args, **kwargs):
super().__init__(*args, **kwargs)
self.logo = assets.logo()
self.setTabsClosable(True)
self.setTabsMovable(True)
self._add_menu(bar)
self.set_window_mode(window_mode)
def _add_menu(self, bar):
@ -22,25 +35,41 @@ class MDIArea(QMdiArea):
menu.addAction("Tile", self.on_tile)
menu.addSeparator()
group = menu.addGroup()
group.addCheckbox("Multiple windows", triggered=self.enable_subwindow_view, state=True)
group.addCheckbox("Multiple windows", triggered=self.enable_multiple_windows_mode, state=True)
group.addCheckbox("Single window", triggered=self.enable_single_window_mode)
group.addCheckbox("Tabbed", triggered=self.enable_tabbed_view)
group.addCheckbox("Tabbed", triggered=self.enable_tabbed_mode)
menu.addSeparator()
menu.addAction("Close all", self.closeAllSubWindows)
menu.addAction("Close inactive", self.closeInactiveSubWindows)
def set_window_mode(self, mode: MDIWindowMode) -> None:
mode_enablers = {
MDIWindowMode.SINGLE: self.enable_single_window_mode,
MDIWindowMode.MULTI: self.enable_multiple_windows_mode,
MDIWindowMode.TABS: self.enable_tabbed_mode
}
enable_mode = mode_enablers[mode]
enable_mode()
def on_cascade(self):
self.menu.checkboxes["Multiple windows"].setChecked(True)
self.enable_subwindow_view()
self.enable_multiple_windows_mode()
self.cascadeSubWindows()
def on_tile(self):
self.menu.checkboxes["Multiple windows"].setChecked(True)
self.enable_subwindow_view()
self.enable_multiple_windows_mode()
self.tileSubWindows()
def enable_multiple_windows_mode(self):
self.menu.checkboxes["Multiple windows"].setChecked(True)
self.enable_subwindow_view()
for sub in self.subWindowList():
sub.frame_on()
def enable_single_window_mode(self):
self.menu.checkboxes["Single window"].setChecked(True)
self.enable_subwindow_view()
self.closeInactiveSubWindows()
active = self.activeSubWindow()
@ -48,10 +77,13 @@ class MDIArea(QMdiArea):
active.showMaximized()
active.frame_off()
def enable_tabbed_mode(self):
self.menu.checkboxes["Tabbed"].setChecked(True)
self.enable_tabbed_view()
def enable_subwindow_view(self):
self.setViewMode(QMdiArea.SubWindowView)
for sub in self.subWindowList():
sub.frame_on()
def enable_tabbed_view(self):
self.setViewMode(QMdiArea.TabbedView)

View File

@ -8,4 +8,12 @@ class RPCClient(xrc.ServerProxy):
super().__init__(uri, *args, **kwargs)
def __repr__(self):
orig = super().__repr__()
head = orig.strip("<>").rstrip("/")
head += " exposing:\n\n"
help = self.utils.help()
return head + help

View File

@ -1,12 +1,56 @@
import xmlrpc.server as xrs
from inspect import getdoc, signature
class RPCServer(xrs.SimpleXMLRPCServer):
class RPCServer(xrs.DocXMLRPCServer):
def __init__(self, host, port, *args, **kwargs):
def __init__(self, host, port, doc_title_suffix="", *args, **kwargs):
addr = (host, port)
kwargs.setdefault("allow_none", True)
super().__init__(addr, *args, **kwargs)
if doc_title_suffix:
self.server_name = doc_title_suffix + " " + self.server_name
self.server_title = doc_title_suffix + " " + self.server_title
self.register_function(self.ping, name="utils.ping")
self.register_function(self.help, name="utils.help")
self.register_function(self.info, name="utils.info")
def ping(self):
"""
Returns "pong". May be used for testing the connection.
"""
return "pong"
def help(self):
"""
Returns an overview of exposed functions incl. signatures and docstrings.
"""
info = self.info()
lines = []
for name, item in info.items():
sig = item["signature"]
head = f"{name}{sig}:"
underline = "-" * len(head)
doc = item["docstring"]
doc = str(doc) # take care of None
lines += [head, underline, doc, ""]
return "\n".join(lines)
def info(self):
"""
Returns a dict mapping names of exposed functions to dicts holding their signature and docstring.
"""
res = {}
for name, func in self.funcs.items():
doc = getdoc(func)
sig = str(signature(func))
res[name] = dict(signature=sig, docstring=doc)
return res

18
grum/webview.py Normal file
View File

@ -0,0 +1,18 @@
from PyQt5.QtCore import QUrl
from PyQt5.QtWebKitWidgets import QWebView
class WebView(QWebView):
def __init__(self, url, title=None):
super().__init__()
self.url = QUrl(url)
if title:
self.setWindowTitle(title)
def show(self):
self.load(self.url)
super().show()