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 <pedersen+jenkins@frm2.tum.de>
Reviewed-by: Enrico Faulhaber <enrico.faulhaber@frm2.tum.de>
Reviewed-by: Alexander Zaft <a.zaft@fz-juelich.de>
This commit is contained in:
Alexander Zaft 2023-03-23 09:02:50 +01:00 committed by Markus Zolliker
parent 41f3a2ecd4
commit 2f730ab444
5 changed files with 226 additions and 87 deletions

190
frappy/gui/console.py Normal file
View File

@ -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(
'<span style="font-weight:bold">Request:</span> '
'<tt>%s</tt>' % 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('<div style="font-weight: bold">'
'SECoP Communication Shell<br/>'
'=========================<br/></div>',
raw=True)
def _addLogEntry(self, msg, raw=False, error=False):
if not raw:
if error:
msg = ('<div style="color:#FF0000"><b><pre>%s</pre></b></div>'
% toHtmlEscaped(str(msg)).replace('\n', '<br />'))
else:
msg = ('<pre>%s</pre>'
% toHtmlEscaped(str(msg)).replace('\n', '<br />'))
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

View File

@ -22,8 +22,8 @@
# ***************************************************************************** # *****************************************************************************
from frappy.gui.qt import QAction, QInputDialog, QKeySequence, QMainWindow, \ from frappy.gui.qt import QAction, QInputDialog, QKeySequence, QMainWindow, \
QMessageBox, QPixmap, QSettings, QShortcut, Qt, QWidget, pyqtSignal, \ QMessageBox, QObject, QPixmap, QSettings, QShortcut, Qt, QWidget, \
pyqtSlot pyqtSignal, pyqtSlot
import frappy.version import frappy.version
from frappy.gui.connection import QSECNode from frappy.gui.connection import QSECNode
@ -75,6 +75,19 @@ class Greeter(QWidget):
self.addnodes.emit([item.text()]) 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): class MainWindow(QMainWindow):
recentNodesChanged = pyqtSignal() recentNodesChanged = pyqtSignal()
@ -84,6 +97,7 @@ class MainWindow(QMainWindow):
self.log = logger self.log = logger
self.logwin = LogWindow(logger, self) self.logwin = LogWindow(logger, self)
self.logwin.hide() self.logwin.hide()
self.historySerializer = HistorySerializer()
loadUi(self, 'mainwin.ui') loadUi(self, 'mainwin.ui')
Colors._setPalette(self.palette()) Colors._setPalette(self.palette())
@ -194,6 +208,7 @@ class MainWindow(QMainWindow):
node = QSECNode(host, self.log, parent=self) node = QSECNode(host, self.log, parent=self)
nodeWidget = NodeWidget(node) nodeWidget = NodeWidget(node)
nodeWidget.setParent(self) nodeWidget.setParent(self)
nodeWidget.consoleTextSent.connect(self.historySerializer.append)
nodeWidget._rebuildAdvanced(self.actionDetailed_View.isChecked()) nodeWidget._rebuildAdvanced(self.actionDetailed_View.isChecked())
# Node and NodeWidget created without error # Node and NodeWidget created without error
@ -258,4 +273,5 @@ class MainWindow(QMainWindow):
for widget in self._nodeWidgets.values(): for widget in self._nodeWidgets.values():
# this is only qt signals deconnecting! # this is only qt signals deconnecting!
widget.getSecNode().terminate_connection() widget.getSecNode().terminate_connection()
self.historySerializer.saveHistory()
self.logwin.onClose() self.logwin.onClose()

View File

@ -21,14 +21,12 @@
# #
# ***************************************************************************** # *****************************************************************************
import json
from collections import OrderedDict from collections import OrderedDict
from frappy.gui.qt import QCursor, QFont, QFontMetrics, QIcon, QInputDialog, \ from frappy.gui.qt import QCursor, QIcon, QInputDialog, QMenu, QSettings, \
QMenu, QSettings, QTextCursor, QVBoxLayout, QWidget, pyqtSignal, \ QVBoxLayout, QWidget, pyqtSignal
pyqtSlot, toHtmlEscaped
from frappy.errors import SECoPError from frappy.gui.console import Console
from frappy.gui.moduleoverview import ModuleOverview from frappy.gui.moduleoverview import ModuleOverview
from frappy.gui.modulewidget import ModuleWidget from frappy.gui.modulewidget import ModuleWidget
from frappy.gui.paramview import ParameterView from frappy.gui.paramview import ParameterView
@ -36,82 +34,9 @@ from frappy.gui.plotting import getPlotWidget
from frappy.gui.util import Colors, loadUi 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(
'<span style="font-weight:bold">Request:</span> '
'<tt>%s</tt>' % 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('<div style="font-weight: bold">'
'SECoP Communication Shell<br/>'
'=========================<br/></div>',
raw=True)
def _addLogEntry(self, msg, raw=False, error=False):
if not raw:
if error:
msg = ('<div style="color:#FF0000"><b><pre>%s</pre></b></div>'
% toHtmlEscaped(str(msg)).replace('\n', '<br />'))
else:
msg = ('<pre>%s</pre>'
% toHtmlEscaped(str(msg)).replace('\n', '<br />'))
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): class NodeWidget(QWidget):
noPlots = pyqtSignal(bool) noPlots = pyqtSignal(bool)
consoleTextSent = pyqtSignal(str)
def __init__(self, node, parent=None): def __init__(self, node, parent=None):
super().__init__(parent) super().__init__(parent)
@ -139,6 +64,7 @@ class NodeWidget(QWidget):
self.consoleWidget.setTitle('Console') self.consoleWidget.setTitle('Console')
cmd = Console(node, self.consoleWidget) cmd = Console(node, self.consoleWidget)
cmd.msgLineEdit.sentText.connect(self.consoleTextSent.emit)
self.consoleWidget.replaceWidget(cmd) self.consoleWidget.replaceWidget(cmd)
viewLayout = self.viewContent.layout() viewLayout = self.viewContent.layout()

View File

@ -36,8 +36,8 @@ try:
QPointF, QPropertyAnimation, QRectF, QSettings, QSize, Qt, \ QPointF, QPropertyAnimation, QRectF, QSettings, QSize, Qt, \
pyqtProperty, pyqtSignal, pyqtSlot pyqtProperty, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QAction, QBrush, QColor, QCursor, QDrag, QFont, \ from PyQt6.QtGui import QAction, QBrush, QColor, QCursor, QDrag, QFont, \
QFontMetrics, QIcon, QKeySequence, QMouseEvent, QPainter, QPalette, \ QFontMetrics, QIcon, QKeyEvent, QKeySequence, QMouseEvent, QPainter, \
QPen, QPixmap, QPolygonF, QShortcut, QStandardItem, \ QPalette, QPen, QPixmap, QPolygonF, QShortcut, QStandardItem, \
QStandardItemModel, QTextCursor QStandardItemModel, QTextCursor
from PyQt6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, \ from PyQt6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, \
QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFrame, QGridLayout, \ QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFrame, QGridLayout, \
@ -56,9 +56,9 @@ except ImportError as e:
QPointF, QPropertyAnimation, QRectF, QSettings, QSize, Qt, \ QPointF, QPropertyAnimation, QRectF, QSettings, QSize, Qt, \
pyqtProperty, pyqtSignal, pyqtSlot pyqtProperty, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QBrush, QColor, QCursor, QDrag, QFont, \ from PyQt5.QtGui import QBrush, QColor, QCursor, QDrag, QFont, \
QFontMetrics, QIcon, QKeySequence, QMouseEvent, QPainter, QPalette, \ QFontMetrics, QIcon, QKeyEvent, QKeySequence, QMouseEvent, QPainter, \
QPen, QPixmap, QPolygonF, QStandardItem, QStandardItemModel, \ QPalette, QPen, QPixmap, QPolygonF, QStandardItem, \
QTextCursor QStandardItemModel, QTextCursor
from PyQt5.QtWidgets import QAction, QApplication, QCheckBox, QComboBox, \ from PyQt5.QtWidgets import QAction, QApplication, QCheckBox, QComboBox, \
QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFrame, \ QDialog, QDialogButtonBox, QDoubleSpinBox, QFileDialog, QFrame, \
QGridLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, \ QGridLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, \

View File

@ -65,7 +65,7 @@ p, li { white-space: pre-wrap; }
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QLineEdit" name="msgLineEdit"/> <widget class="ConsoleLineEdit" name="msgLineEdit"/>
</item> </item>
<item> <item>
<widget class="QPushButton" name="clearPushButton"> <widget class="QPushButton" name="clearPushButton">
@ -91,6 +91,13 @@ p, li { white-space: pre-wrap; }
</item> </item>
</layout> </layout>
</widget> </widget>
<customwidgets>
<customwidget>
<class>ConsoleLineEdit</class>
<extends>QLineEdit</extends>
<header>frappy.gui.console.h</header>
</customwidget>
</customwidgets>
<resources/> <resources/>
<connections> <connections>
<connection> <connection>