From 2f730ab44442c3caaf209b6099288b67d7efbcd2 Mon Sep 17 00:00:00 2001 From: Alexander Zaft Date: Thu, 23 Mar 2023 09:02:50 +0100 Subject: [PATCH] gui: add console history + add HistorySerializer to merge histories in correct order * move console to own file * promote msgLineEdit to class based on NICOS-HistoryLineEdit Change-Id: I853d49a70640f38275c8762ab345003db5ec5592 Reviewed-on: https://forge.frm2.tum.de/review/c/secop/frappy/+/30753 Tested-by: Jenkins Automated Tests Reviewed-by: Enrico Faulhaber Reviewed-by: Alexander Zaft --- frappy/gui/console.py | 190 +++++++++++++++++++++++++++++++++++++++ frappy/gui/mainwindow.py | 20 ++++- frappy/gui/nodewidget.py | 84 ++--------------- frappy/gui/qt.py | 10 +-- frappy/gui/ui/console.ui | 9 +- 5 files changed, 226 insertions(+), 87 deletions(-) create mode 100644 frappy/gui/console.py diff --git a/frappy/gui/console.py b/frappy/gui/console.py new file mode 100644 index 0000000..9369aea --- /dev/null +++ b/frappy/gui/console.py @@ -0,0 +1,190 @@ + +import json + +from frappy.gui.qt import QApplication, QFont, QFontMetrics, QKeyEvent, \ + QLineEdit, QSettings, Qt, QTextCursor, QWidget, pyqtSignal, pyqtSlot, \ + toHtmlEscaped + +from frappy.errors import SECoPError +from frappy.gui.util import loadUi + + +class ConsoleLineEdit(QLineEdit): + """QLineEdit with history. Based on HistoryLineEdit from NICOS gui""" + sentText = pyqtSignal(str) + scrollingKeys = [Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_PageUp, + Qt.Key.Key_PageDown] + + def __init__(self, parent=None): + super().__init__(parent) + settings = QSettings() + self.history = settings.value('consoleHistory', []) + self.scrollWidget = None + self._start_text = '' + self._current = -1 + + def keyPressEvent(self, kev): + key_code = kev.key() + + # if it's a shifted scroll key... + if kev.modifiers() & Qt.KeyboardModifier.ShiftModifier and \ + self.scrollWidget and \ + key_code in self.scrollingKeys: + # create a new, unshifted key event and send it to the + # scrolling widget + nev = QKeyEvent(kev.type(), kev.key(), + Qt.KeyboardModifier.NoModifier) + QApplication.sendEvent(self.scrollWidget, nev) + return + + if key_code == Qt.Key.Key_Escape: + # abort history search + self.setText(self._start_text) + self._current = -1 + QLineEdit.keyPressEvent(self, kev) + + elif key_code == Qt.Key.Key_Up: + # go earlier + if self._current == -1: + self._start_text = self.text() + self._current = len(self.history) + self.stepHistory(-1) + elif key_code == Qt.Key.Key_Down: + # go later + if self._current == -1: + return + self.stepHistory(1) + + elif key_code == Qt.Key.Key_PageUp: + # go earlier with prefix + if self._current == -1: + self._current = len(self.history) + self._start_text = self.text() + prefix = self.text()[:self.cursorPosition()] + self.stepHistoryUntil(prefix, 'up') + + elif key_code == Qt.Key.Key_PageDown: + # go later with prefix + if self._current == -1: + return + prefix = self.text()[:self.cursorPosition()] + self.stepHistoryUntil(prefix, 'down') + + elif key_code in (Qt.Key.Key_Return, key_code == Qt.Key.Key_Enter): + # accept - add to history and do normal processing + self._current = -1 + text = self.text() + if text and (not self.history or self.history[-1] != text): + # append to history, but only if it isn't equal to the last + self.history.append(text) + self.sentText.emit(text) + QLineEdit.keyPressEvent(self, kev) + + else: + # process normally + QLineEdit.keyPressEvent(self, kev) + + def stepHistory(self, num): + self._current += num + if self._current <= -1: + # no further + self._current = 0 + return + if self._current >= len(self.history): + # back to start + self._current = -1 + self.setText(self._start_text) + return + self.setText(self.history[self._current]) + + def stepHistoryUntil(self, prefix, direction): + if direction == 'up': + lookrange = range(self._current - 1, -1, -1) + else: + lookrange = range(self._current + 1, len(self.history)) + for i in lookrange: + if self.history[i].startswith(prefix): + self._current = i + self.setText(self.history[i]) + self.setCursorPosition(len(prefix)) + return + if direction == 'down': + # nothing found: go back to start + self._current = -1 + self.setText(self._start_text) + self.setCursorPosition(len(prefix)) + + +class Console(QWidget): + def __init__(self, node, parent=None): + super().__init__(parent) + loadUi(self, 'console.ui') + self._node = node + self._clearLog() + self.msgLineEdit.scrollWidget = self.logTextBrowser + + @pyqtSlot() + def on_sendPushButton_clicked(self): + msg = self.msgLineEdit.text().strip() + + if not msg: + return + + self._addLogEntry( + 'Request: ' + '%s' % toHtmlEscaped(msg), + raw=True) + # msg = msg.split(' ', 2) + try: + reply = self._node.syncCommunicate(*self._node.decode_message(msg)) + if msg == 'describe': + _, eid, stuff = self._node.decode_message(reply) + reply = '%s %s %s' % (_, eid, json.dumps( + stuff, indent=2, separators=(',', ':'), sort_keys=True)) + self._addLogEntry(reply.rstrip('\n')) + else: + self._addLogEntry(reply.rstrip('\n')) + except SECoPError as e: + einfo = e.args[0] if len(e.args) == 1 else json.dumps(e.args) + self._addLogEntry('%s: %s' % (e.name, einfo), error=True) + except Exception as e: + self._addLogEntry('error when sending %r: %r' % (msg, e), + error=True) + + self.msgLineEdit.clear() + + @pyqtSlot() + def on_clearPushButton_clicked(self): + self._clearLog() + + def _clearLog(self): + self.logTextBrowser.clear() + + self._addLogEntry('
' + 'SECoP Communication Shell
' + '=========================
', + raw=True) + + def _addLogEntry(self, msg, raw=False, error=False): + if not raw: + if error: + msg = ('
%s
' + % toHtmlEscaped(str(msg)).replace('\n', '
')) + else: + msg = ('
%s
' + % toHtmlEscaped(str(msg)).replace('\n', '
')) + + content = '' + if self.logTextBrowser.toPlainText(): + content = self.logTextBrowser.toHtml() + content += msg + + self.logTextBrowser.setHtml(content) + self.logTextBrowser.moveCursor(QTextCursor.MoveOperation.End) + + def _getLogWidth(self): + fontMetrics = QFontMetrics(QFont('Monospace')) + # calculate max avail characters by using an m (which is possible + # due to monospace) + result = self.logTextBrowser.width() / fontMetrics.width('m') + return result diff --git a/frappy/gui/mainwindow.py b/frappy/gui/mainwindow.py index d5eb762..afe4450 100644 --- a/frappy/gui/mainwindow.py +++ b/frappy/gui/mainwindow.py @@ -22,8 +22,8 @@ # ***************************************************************************** from frappy.gui.qt import QAction, QInputDialog, QKeySequence, QMainWindow, \ - QMessageBox, QPixmap, QSettings, QShortcut, Qt, QWidget, pyqtSignal, \ - pyqtSlot + QMessageBox, QObject, QPixmap, QSettings, QShortcut, Qt, QWidget, \ + pyqtSignal, pyqtSlot import frappy.version from frappy.gui.connection import QSECNode @@ -75,6 +75,19 @@ class Greeter(QWidget): self.addnodes.emit([item.text()]) +class HistorySerializer(QObject): + def __init__(self, parent=None): + super().__init__(parent) + settings = QSettings() + self.history = settings.value('consoleHistory', []) + + def append(self, text): + self.history.append(text) + + def saveHistory(self): + settings = QSettings() + settings.setValue('consoleHistory', self.history) + class MainWindow(QMainWindow): recentNodesChanged = pyqtSignal() @@ -84,6 +97,7 @@ class MainWindow(QMainWindow): self.log = logger self.logwin = LogWindow(logger, self) self.logwin.hide() + self.historySerializer = HistorySerializer() loadUi(self, 'mainwin.ui') Colors._setPalette(self.palette()) @@ -194,6 +208,7 @@ class MainWindow(QMainWindow): node = QSECNode(host, self.log, parent=self) nodeWidget = NodeWidget(node) nodeWidget.setParent(self) + nodeWidget.consoleTextSent.connect(self.historySerializer.append) nodeWidget._rebuildAdvanced(self.actionDetailed_View.isChecked()) # Node and NodeWidget created without error @@ -258,4 +273,5 @@ class MainWindow(QMainWindow): for widget in self._nodeWidgets.values(): # this is only qt signals deconnecting! widget.getSecNode().terminate_connection() + self.historySerializer.saveHistory() self.logwin.onClose() diff --git a/frappy/gui/nodewidget.py b/frappy/gui/nodewidget.py index 3b7c78c..f71f3a7 100644 --- a/frappy/gui/nodewidget.py +++ b/frappy/gui/nodewidget.py @@ -21,14 +21,12 @@ # # ***************************************************************************** -import json from collections import OrderedDict -from frappy.gui.qt import QCursor, QFont, QFontMetrics, QIcon, QInputDialog, \ - QMenu, QSettings, QTextCursor, QVBoxLayout, QWidget, pyqtSignal, \ - pyqtSlot, toHtmlEscaped +from frappy.gui.qt import QCursor, QIcon, QInputDialog, QMenu, QSettings, \ + QVBoxLayout, QWidget, pyqtSignal -from frappy.errors import SECoPError +from frappy.gui.console import Console from frappy.gui.moduleoverview import ModuleOverview from frappy.gui.modulewidget import ModuleWidget from frappy.gui.paramview import ParameterView @@ -36,82 +34,9 @@ from frappy.gui.plotting import getPlotWidget from frappy.gui.util import Colors, loadUi -class Console(QWidget): - def __init__(self, node, parent=None): - super().__init__(parent) - loadUi(self, 'console.ui') - self._node = node - self._clearLog() - - @pyqtSlot() - def on_sendPushButton_clicked(self): - msg = self.msgLineEdit.text().strip() - - if not msg: - return - - self._addLogEntry( - 'Request: ' - '%s' % toHtmlEscaped(msg), - raw=True) - # msg = msg.split(' ', 2) - try: - reply = self._node.syncCommunicate(*self._node.decode_message(msg)) - if msg == 'describe': - _, eid, stuff = self._node.decode_message(reply) - reply = '%s %s %s' % (_, eid, json.dumps( - stuff, indent=2, separators=(',', ':'), sort_keys=True)) - self._addLogEntry(reply.rstrip('\n')) - else: - self._addLogEntry(reply.rstrip('\n')) - except SECoPError as e: - einfo = e.args[0] if len(e.args) == 1 else json.dumps(e.args) - self._addLogEntry('%s: %s' % (e.name, einfo), error=True) - except Exception as e: - self._addLogEntry('error when sending %r: %r' % (msg, e), - error=True) - - self.msgLineEdit.selectAll() - - @pyqtSlot() - def on_clearPushButton_clicked(self): - self._clearLog() - - def _clearLog(self): - self.logTextBrowser.clear() - - self._addLogEntry('
' - 'SECoP Communication Shell
' - '=========================
', - raw=True) - - def _addLogEntry(self, msg, raw=False, error=False): - if not raw: - if error: - msg = ('
%s
' - % toHtmlEscaped(str(msg)).replace('\n', '
')) - else: - msg = ('
%s
' - % toHtmlEscaped(str(msg)).replace('\n', '
')) - - content = '' - if self.logTextBrowser.toPlainText(): - content = self.logTextBrowser.toHtml() - content += msg - - self.logTextBrowser.setHtml(content) - self.logTextBrowser.moveCursor(QTextCursor.MoveOperation.End) - - def _getLogWidth(self): - fontMetrics = QFontMetrics(QFont('Monospace')) - # calculate max avail characters by using an m (which is possible - # due to monospace) - result = self.logTextBrowser.width() / fontMetrics.width('m') - return result - - class NodeWidget(QWidget): noPlots = pyqtSignal(bool) + consoleTextSent = pyqtSignal(str) def __init__(self, node, parent=None): super().__init__(parent) @@ -139,6 +64,7 @@ class NodeWidget(QWidget): self.consoleWidget.setTitle('Console') cmd = Console(node, self.consoleWidget) + cmd.msgLineEdit.sentText.connect(self.consoleTextSent.emit) self.consoleWidget.replaceWidget(cmd) viewLayout = self.viewContent.layout() diff --git a/frappy/gui/qt.py b/frappy/gui/qt.py index c4c110e..7c671e3 100644 --- a/frappy/gui/qt.py +++ b/frappy/gui/qt.py @@ -36,8 +36,8 @@ try: QPointF, QPropertyAnimation, QRectF, QSettings, QSize, Qt, \ pyqtProperty, pyqtSignal, pyqtSlot from PyQt6.QtGui import QAction, QBrush, QColor, QCursor, QDrag, QFont, \ - QFontMetrics, QIcon, QKeySequence, QMouseEvent, QPainter, QPalette, \ - QPen, QPixmap, QPolygonF, QShortcut, QStandardItem, \ + QFontMetrics, QIcon, QKeyEvent, QKeySequence, QMouseEvent, QPainter, \ + QPalette, QPen, QPixmap, QPolygonF, QShortcut, QStandardItem, \ QStandardItemModel, QTextCursor from PyQt6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, \ QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFrame, QGridLayout, \ @@ -56,9 +56,9 @@ except ImportError as e: QPointF, QPropertyAnimation, QRectF, QSettings, QSize, Qt, \ pyqtProperty, pyqtSignal, pyqtSlot from PyQt5.QtGui import QBrush, QColor, QCursor, QDrag, QFont, \ - QFontMetrics, QIcon, QKeySequence, QMouseEvent, QPainter, QPalette, \ - QPen, QPixmap, QPolygonF, QStandardItem, QStandardItemModel, \ - QTextCursor + QFontMetrics, QIcon, QKeyEvent, QKeySequence, QMouseEvent, QPainter, \ + QPalette, QPen, QPixmap, QPolygonF, QStandardItem, \ + QStandardItemModel, QTextCursor from PyQt5.QtWidgets import QAction, QApplication, QCheckBox, QComboBox, \ QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFrame, \ QGridLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, \ diff --git a/frappy/gui/ui/console.ui b/frappy/gui/ui/console.ui index bf7722d..66cc251 100644 --- a/frappy/gui/ui/console.ui +++ b/frappy/gui/ui/console.ui @@ -65,7 +65,7 @@ p, li { white-space: pre-wrap; } - + @@ -91,6 +91,13 @@ p, li { white-space: pre-wrap; } + + + ConsoleLineEdit + QLineEdit +
frappy.gui.console.h
+
+