Browse Source

UX improvements around MAVLink Console (#9266)

* mavlink console: use TextArea and other improvements

This is a bit slower, so we do some mitigations:
- limit update rate to 30hz
- limit history (see next commit)

changes:
- user can select & copy output
- auto-scrolling: update is paused when scrolling up, any key input will
  scroll to the bottom
- feel is more like a shell w/o extra text field.
  (on mobile there's still a separate text field as input is handled
  differently)
- handle multi-line commands from clipboard
- coloring of ERROR's and WARNing's
- set text focus to shell input

* MavlinkConsoleController: limit history to 500 lines

As CPU load for text change increases with more history due to the use of
TextArea.

* MavlinkConsoleController: add cursor x position support

Fixes handling of the 'erase current line from cursor' (K) command.

The bug was apparent for example when 'nsh> <ESC>[K' was split into several
packets. The first part was cleared when receiving the 2. packet.

* MavlinkConsoleController: newline handling fixes

- do not add a newline when entering a command
- ensure line exists when moving the cursor down
QGC4.4
Beat Küng 4 years ago committed by GitHub
parent
commit
e35f2f654b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 108
      src/AnalyzeView/MavlinkConsoleController.cc
  2. 25
      src/AnalyzeView/MavlinkConsoleController.h
  3. 271
      src/AnalyzeView/MavlinkConsolePage.qml

108
src/AnalyzeView/MavlinkConsoleController.cc

@ -11,11 +11,10 @@ @@ -11,11 +11,10 @@
#include "QGCApplication.h"
#include "UAS.h"
#include <QClipboard>
MavlinkConsoleController::MavlinkConsoleController()
: QStringListModel(),
_cursor_home_pos{-1},
_cursor{0},
_vehicle{nullptr}
: QStringListModel()
{
auto *manager = qgcApp()->toolbox()->multiVehicleManager();
connect(manager, &MultiVehicleManager::activeVehicleChanged, this, &MavlinkConsoleController::_setActiveVehicle);
@ -33,11 +32,16 @@ MavlinkConsoleController::~MavlinkConsoleController() @@ -33,11 +32,16 @@ MavlinkConsoleController::~MavlinkConsoleController()
void
MavlinkConsoleController::sendCommand(QString command)
{
_history.append(command);
// there might be multiple commands, add them separately to the history
QStringList lines = command.split('\n');
for (int i = 0; i < lines.size(); ++i) {
if (lines[i].size() > 0) {
_history.append(lines[i]);
}
}
command.append("\n");
_sendSerialData(qPrintable(command));
_cursor_home_pos = -1;
_cursor = rowCount();
}
QString
@ -52,6 +56,19 @@ MavlinkConsoleController::historyDown(const QString& current) @@ -52,6 +56,19 @@ MavlinkConsoleController::historyDown(const QString& current)
return _history.down(current);
}
QString
MavlinkConsoleController::handleClipboard(const QString& command_pre)
{
QString clipboardData = command_pre + QApplication::clipboard()->text();
int lastLinePos = clipboardData.lastIndexOf('\n');
if (lastLinePos != -1) {
QString commands = clipboardData.mid(0, lastLinePos);
sendCommand(commands);
clipboardData = clipboardData.mid(lastLinePos+1);
}
return clipboardData;
}
void
MavlinkConsoleController::_setActiveVehicle(Vehicle* vehicle)
{
@ -65,7 +82,8 @@ MavlinkConsoleController::_setActiveVehicle(Vehicle* vehicle) @@ -65,7 +82,8 @@ MavlinkConsoleController::_setActiveVehicle(Vehicle* vehicle)
_incoming_buffer.clear();
// Reset the model
setStringList(QStringList());
_cursor = 0;
_cursorY = 0;
_cursorX = 0;
_cursor_home_pos = -1;
_uas_connections << connect(_vehicle, &Vehicle::mavlinkSerialControl, this, &MavlinkConsoleController::_receiveData);
}
@ -91,9 +109,16 @@ MavlinkConsoleController::_receiveData(uint8_t device, uint8_t, uint16_t, uint32 @@ -91,9 +109,16 @@ MavlinkConsoleController::_receiveData(uint8_t device, uint8_t, uint16_t, uint32
QByteArray fragment = _incoming_buffer.mid(0, idx);
if (_processANSItext(fragment)) {
writeLine(_cursor, fragment);
if (newline)
_cursor++;
writeLine(_cursorY, fragment);
if (newline) {
_cursorY++;
_cursorX = 0;
// ensure line exists
int rc = rowCount();
if (_cursorY >= rc) {
insertRows(rc, 1 + _cursorY - rc);
}
}
_incoming_buffer.remove(0, idx + (newline ? 1 : 0));
} else {
// ANSI processing failed, need more data
@ -154,16 +179,22 @@ MavlinkConsoleController::_processANSItext(QByteArray &line) @@ -154,16 +179,22 @@ MavlinkConsoleController::_processANSItext(QByteArray &line)
case 'H':
if (_cursor_home_pos == -1) {
// Assign new home position if home is unset
_cursor_home_pos = _cursor;
_cursor_home_pos = _cursorY;
} else {
// Rewind write cursor position to home
_cursor = _cursor_home_pos;
_cursorY = _cursor_home_pos;
_cursorX = 0;
}
break;
case 'K':
// Erase the current line to the end
if (_cursor < rowCount()) {
setData(index(_cursor), "");
if (_cursorY < rowCount()) {
auto idx = index(_cursorY);
QString updated = data(idx, Qt::DisplayRole).toString();
int eraseIdx = _cursorX + i;
if (eraseIdx < updated.length()) {
setData(idx, updated.remove(eraseIdx, updated.length()));
}
}
break;
case '2':
@ -177,11 +208,8 @@ MavlinkConsoleController::_processANSItext(QByteArray &line) @@ -177,11 +208,8 @@ MavlinkConsoleController::_processANSItext(QByteArray &line)
for (int j = _cursor_home_pos; j < rowCount(); j++)
setData(index(j), "");
blockSignals(blocked);
QVector<int> roles;
roles.reserve(2);
roles.append(Qt::DisplayRole);
roles.append(Qt::EditRole);
emit dataChanged(index(_cursor), index(rowCount()), roles);
QVector<int> roles({Qt::DisplayRole, Qt::EditRole});
emit dataChanged(index(_cursorY), index(rowCount()), roles);
}
// Even if we didn't understand this ANSI code, remove the 4th char
line.remove(i+3,1);
@ -200,6 +228,34 @@ MavlinkConsoleController::_processANSItext(QByteArray &line) @@ -200,6 +228,34 @@ MavlinkConsoleController::_processANSItext(QByteArray &line)
return true;
}
QString
MavlinkConsoleController::transformLineForRichText(const QString& line) const
{
QString ret = line.toHtmlEscaped().replace(" ","&nbsp;").replace("\t", "&nbsp;&nbsp;&nbsp;&nbsp;");
if (ret.startsWith("WARN", Qt::CaseSensitive)) {
ret.replace(0, 4, "<font color=\"" + _palette.colorOrange().name() + "\">WARN</font>");
} else if (ret.startsWith("ERROR", Qt::CaseSensitive)) {
ret.replace(0, 5, "<font color=\"" + _palette.colorRed().name() + "\">ERROR</font>");
}
return ret;
}
QString
MavlinkConsoleController::getText() const
{
QString ret;
if (rowCount() > 0) {
ret = transformLineForRichText(data(index(0), Qt::DisplayRole).toString());
}
for (int i = 1; i < rowCount(); ++i) {
ret += "<br>" + transformLineForRichText(data(index(i), Qt::DisplayRole).toString());
}
return ret;
}
void
MavlinkConsoleController::writeLine(int line, const QByteArray &text)
{
@ -207,8 +263,20 @@ MavlinkConsoleController::writeLine(int line, const QByteArray &text) @@ -207,8 +263,20 @@ MavlinkConsoleController::writeLine(int line, const QByteArray &text)
if (line >= rc) {
insertRows(rc, 1 + line - rc);
}
if (rowCount() > _max_num_lines) {
int count = rowCount() - _max_num_lines;
removeRows(0, count);
line -= count;
_cursorY -= count;
_cursor_home_pos -= count;
if (_cursor_home_pos < 0)
_cursor_home_pos = -1;
}
auto idx = index(line);
setData(idx, data(idx, Qt::DisplayRole).toString() + text);
QString updated = data(idx, Qt::DisplayRole).toString();
updated.replace(_cursorX, text.size(), text);
setData(idx, updated);
_cursorX += text.size();
}
void MavlinkConsoleController::CommandHistory::append(const QString& command)

25
src/AnalyzeView/MavlinkConsoleController.h

@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
#pragma once
#include "QmlObjectListModel.h"
#include "QGCPalette.h"
#include "Fact.h"
#include "FactMetaData.h"
#include <QObject>
@ -34,6 +35,15 @@ public: @@ -34,6 +35,15 @@ public:
Q_INVOKABLE QString historyUp(const QString& current);
Q_INVOKABLE QString historyDown(const QString& current);
/**
* Get clipboard data and if it has N lines, execute first N-1 commands
* @param command_pre prefix to data from clipboard
* @return last line of the clipboard data
*/
Q_INVOKABLE QString handleClipboard(const QString& command_pre);
Q_PROPERTY(QString text READ getText CONSTANT)
private slots:
void _setActiveVehicle (Vehicle* vehicle);
void _receiveData(uint8_t device, uint8_t flags, uint16_t timeout, uint32_t baudrate, QByteArray data);
@ -43,6 +53,10 @@ private: @@ -43,6 +53,10 @@ private:
void _sendSerialData(QByteArray, bool close = false);
void writeLine(int line, const QByteArray &text);
QString transformLineForRichText(const QString& line) const;
QString getText() const;
class CommandHistory
{
public:
@ -55,11 +69,14 @@ private: @@ -55,11 +69,14 @@ private:
int _index = 0;
};
int _cursor_home_pos;
int _cursor;
static constexpr int _max_num_lines = 500; ///< history size (affects CPU load)
int _cursor_home_pos{-1};
int _cursorY{0};
int _cursorX{0};
QByteArray _incoming_buffer;
Vehicle* _vehicle;
Vehicle* _vehicle{nullptr};
QList<QMetaObject::Connection> _uas_connections;
CommandHistory _history;
QGCPalette _palette;
};

271
src/AnalyzeView/MavlinkConsolePage.qml

@ -8,7 +8,8 @@ @@ -8,7 +8,8 @@
****************************************************************************/
import QtQuick 2.3
import QtQuick.Controls 1.2
import QtQuick.Controls 1.3
import QtQuick.Controls.Styles 1.4
import QtQuick.Dialogs 1.2
import QtQuick.Layouts 1.2
@ -28,6 +29,10 @@ AnalyzePage { @@ -28,6 +29,10 @@ AnalyzePage {
property bool isLoaded: false
// Key input on mobile is handled differently, so use a separate command input text field.
// E.g. for android see https://bugreports.qt.io/browse/QTBUG-40803
readonly property bool _separateCommandInput: ScreenTools.isMobile
MavlinkConsoleController {
id: conController
}
@ -44,54 +49,230 @@ AnalyzePage { @@ -44,54 +49,230 @@ AnalyzePage {
target: conController
onDataChanged: {
// Keep the view in sync if the button is checked
if (isLoaded) {
if (followTail.checked) {
listview.positionViewAtEnd();
}
// rate-limit updates to reduce CPU load
updateTimer.start();
}
}
}
Component {
id: delegateItem
Rectangle {
color: qgcPal.windowShade
height: Math.round(ScreenTools.defaultFontPixelHeight * 0.1 + field.height)
width: listview.width
QGCLabel {
id: field
text: display
width: parent.width
wrapMode: Text.NoWrap
font.family: ScreenTools.fixedFontFamily
anchors.verticalCenter: parent.verticalCenter
property int _consoleOutputLen: 0
function scrollToBottom() {
var flickable = textConsole.flickableItem
if (flickable.contentHeight > flickable.height)
flickable.contentY = flickable.contentHeight-flickable.height
}
function getCommand() {
return textConsole.getText(_consoleOutputLen, textConsole.length)
}
function getCommandAndClear() {
var command = getCommand()
textConsole.remove(_consoleOutputLen, textConsole.length)
return command
}
function pasteFromClipboard() {
// we need to handle a few cases here:
// in the general form we have: <command_pre><cursor><command_post>
// and the clipboard may contain newlines
var cursor = textConsole.cursorPosition - _consoleOutputLen
var command = getCommandAndClear()
var command_pre = ""
var command_post = command
if (cursor > 0) {
command_pre = command.substr(0, cursor)
command_post = command.substr(cursor)
}
var command_leftover = conController.handleClipboard(command_pre) + command_post
textConsole.insert(textConsole.length, command_leftover)
textConsole.cursorPosition = textConsole.length - command_post.length
}
Timer {
id: updateTimer
interval: 30
running: false
repeat: false
onTriggered: {
// only update if scroll bar is at the bottom
if (textConsole.flickableItem.atYEnd) {
// backup & restore cursor & command
var command = getCommand()
var cursor = textConsole.cursorPosition - _consoleOutputLen
textConsole.text = conController.text
_consoleOutputLen = textConsole.length
textConsole.insert(textConsole.length, command)
textConsole.cursorPosition = textConsole.length
scrollToBottom()
if (cursor >= 0) {
// We could restore the selection here too...
textConsole.cursorPosition = _consoleOutputLen + cursor
}
} else {
updateTimer.start();
}
}
}
QGCListView {
TextArea {
Component.onCompleted: {
isLoaded = true
_consoleOutputLen = textConsole.length
textConsole.cursorPosition = _consoleOutputLen
if (!_separateCommandInput)
textConsole.forceActiveFocus()
}
id: textConsole
wrapMode: Text.NoWrap
Layout.preferredWidth: parent.width
Layout.fillHeight: true
readOnly: _separateCommandInput
frameVisible: false
textFormat: TextEdit.RichText
inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhMultiLine
text: "> "
focus: true
menu: Menu {
id: contextMenu
MenuItem {
text: qsTr("Copy")
onTriggered: {
textConsole.copy()
}
}
MenuItem {
text: qsTr("Paste")
onTriggered: {
pasteFromClipboard()
}
}
}
style: TextAreaStyle {
textColor: qgcPal.text
backgroundColor: qgcPal.windowShade
selectedTextColor: qgcPal.windowShade
selectionColor: qgcPal.text
font.pointSize: ScreenTools.defaultFontPointSize
font.family: ScreenTools.fixedFontFamily
}
Keys.onPressed: {
if (event.key == Qt.Key_Tab) { // ignore tabs
event.accepted = true
}
if (event.matches(StandardKey.Cut)) {
// ignore for now
event.accepted = true
}
if (!event.matches(StandardKey.Copy) &&
event.key != Qt.Key_Escape &&
event.key != Qt.Key_Insert &&
event.key != Qt.Key_Pause &&
event.key != Qt.Key_Print &&
event.key != Qt.Key_SysReq &&
event.key != Qt.Key_Clear &&
event.key != Qt.Key_Home &&
event.key != Qt.Key_End &&
event.key != Qt.Key_Left &&
event.key != Qt.Key_Up &&
event.key != Qt.Key_Right &&
event.key != Qt.Key_Down &&
event.key != Qt.Key_PageUp &&
event.key != Qt.Key_PageDown &&
event.key != Qt.Key_Shift &&
event.key != Qt.Key_Control &&
event.key != Qt.Key_Meta &&
event.key != Qt.Key_Alt &&
event.key != Qt.Key_AltGr &&
event.key != Qt.Key_CapsLock &&
event.key != Qt.Key_NumLock &&
event.key != Qt.Key_ScrollLock &&
event.key != Qt.Key_Super_L &&
event.key != Qt.Key_Super_R &&
event.key != Qt.Key_Menu &&
event.key != Qt.Key_Hyper_L &&
event.key != Qt.Key_Hyper_R &&
event.key != Qt.Key_Direction_L &&
event.key != Qt.Key_Direction_R) {
// Note: dead keys do not generate keyPressed event on linux, see
// https://bugreports.qt.io/browse/QTBUG-79216
scrollToBottom()
// ensure cursor position is at an editable region
if (textConsole.selectionStart < _consoleOutputLen) {
textConsole.select(_consoleOutputLen, textConsole.selectionEnd)
}
if (textConsole.cursorPosition < _consoleOutputLen) {
textConsole.cursorPosition = textConsole.length
}
}
if (event.key == Qt.Key_Left) {
// don't move beyond current command
if (textConsole.cursorPosition == _consoleOutputLen) {
event.accepted = true
}
}
if (event.key == Qt.Key_Backspace) {
if (textConsole.cursorPosition <= _consoleOutputLen) {
event.accepted = true
}
}
if (event.key == Qt.Key_Enter || event.key == Qt.Key_Return) {
conController.sendCommand(getCommandAndClear())
event.accepted = true
}
if (event.matches(StandardKey.Paste)) {
pasteFromClipboard()
event.accepted = true
}
// command history
if (event.modifiers == Qt.NoModifier && event.key == Qt.Key_Up) {
var command = conController.historyUp(getCommandAndClear())
textConsole.insert(textConsole.length, command)
textConsole.cursorPosition = textConsole.length
event.accepted = true
} else if (event.modifiers == Qt.NoModifier && event.key == Qt.Key_Down) {
var command = conController.historyDown(getCommandAndClear())
textConsole.insert(textConsole.length, command)
textConsole.cursorPosition = textConsole.length
event.accepted = true
}
}
Layout.fillHeight: true
Layout.fillWidth: true
clip: true
id: listview
model: conController
delegate: delegateItem
// Unsync the view if the user interacts
onMovementStarted: {
followTail.checked = false
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.MiddleButton
onClicked: {
// disable middle-click pasting (we could add support for that if needed)
}
onWheel: {
// increase scrolling speed (the default is a single line)
var numLines = 4
var flickable = textConsole.flickableItem
var dy = wheel.angleDelta.y * numLines / 120 * textConsole.font.pixelSize
flickable.contentY = Math.max(0, Math.min(flickable.contentHeight - flickable.height, flickable.contentY - dy))
if (wheel.angleDelta.x != 0) {
var dx = wheel.angleDelta.x * numLines / 120 * textConsole.font.pixelSize
flickable.contentX = Math.max(0, Math.min(flickable.contentWidth - flickable.width, flickable.contentX - dx))
}
wheel.accepted = true
}
}
}
RowLayout {
Layout.fillWidth: true
visible: _separateCommandInput
QGCTextField {
id: command
id: commandInput
Layout.fillWidth: true
placeholderText: "Enter Commands here..."
inputMethodHints: Qt.ImhNoAutoUppercase
@ -99,39 +280,15 @@ AnalyzePage { @@ -99,39 +280,15 @@ AnalyzePage {
function sendCommand() {
conController.sendCommand(text)
text = ""
scrollToBottom()
}
onAccepted: sendCommand()
Keys.onPressed: {
if (event.key === Qt.Key_Up) {
text = conController.historyUp(text);
event.accepted = true;
} else if (event.key === Qt.Key_Down) {
text = conController.historyDown(text);
event.accepted = true;
}
}
}
QGCButton {
id: sendButton
text: qsTr("Send")
visible: ScreenTools.isMobile
onClicked: command.sendCommand()
}
QGCButton {
id: followTail
text: qsTr("Show Latest")
checkable: true
checked: true
onCheckedChanged: {
if (checked && isLoaded) {
listview.positionViewAtEnd();
}
}
onClicked: commandInput.sendCommand()
}
}
}

Loading…
Cancel
Save