Merge branch 'master' into 'tests'
# Conflicts: # grum/mainwin.py
This commit is contained in:
34
README.md
34
README.md
@ -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()`
|
||||
|
28
grum/cli.py
28
grum/cli.py
@ -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
|
||||
|
||||
|
||||
|
||||
|
49
grum/dictlist/timestamps.py
Normal file
49
grum/dictlist/timestamps.py
Normal 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)
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -1,5 +1,5 @@
|
||||
|
||||
from .mdiarea import MDIArea
|
||||
from .mdiarea import MDIArea, MDIWindowMode
|
||||
from .mdisubplot import MDISubPlot, MDISubMultiPlot
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
@ -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
18
grum/webview.py
Normal 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()
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user