[ui] New TextFileViewer for displaying log files

Introduce a new TextFileViewer component with auto-reload feature based on a ListView instead of a TextArea for performance reasons. Uses the text content split on line breaks as ListView's model.
Features: 
 * auto-scroll to bottom
 * display line numbers
 * customizable line delegates (e.g.: display a real progress bar)
 * color lines according to log level
This commit is contained in:
Yann Lanthony 2019-05-07 11:45:26 +02:00
parent 438622a14b
commit 55dba55d19
No known key found for this signature in database
GPG key ID: 519FAE6DF7A70642
4 changed files with 363 additions and 173 deletions

View file

@ -0,0 +1,331 @@
import QtQuick 2.11
import QtQml.Models 2.11
import QtQuick.Controls 2.5
import QtQuick.Layouts 1.11
import MaterialIcons 2.2
import Utils 1.0
/**
* Text file viewer with auto-reload feature.
* Uses a ListView with one delegate by line instead of a TextArea for performance reasons.
*/
Item {
id: root
/// Source text file to load
property url source
/// Whether to periodically reload the source file
property bool autoReload: false
/// Interval (in ms) at which source file should be reloaded if autoReload is enabled
property int autoReloadInterval: 2000
/// Whether the source is currently being loaded
property bool loading: false
onSourceChanged: loadSource()
onVisibleChanged: if(visible) loadSource()
RowLayout {
anchors.fill: parent
spacing: 0
// Toolbar
Pane {
Layout.alignment: Qt.AlignTop
Layout.fillHeight: true
padding: 0
background: Rectangle { color: Qt.darker(Colors.sysPalette.window, 1.2) }
Column {
height: parent.height
ToolButton {
text: MaterialIcons.refresh
ToolTip.text: "Reload"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
onClicked: loadSource(false)
}
ToolButton {
text: MaterialIcons.vertical_align_top
ToolTip.text: "Scroll to Top"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
onClicked: textView.positionViewAtBeginning()
}
ToolButton {
text: MaterialIcons.vertical_align_bottom
ToolTip.text: "Scroll to Bottom"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
onClicked: textView.positionViewAtEnd()
checkable: false
checked: textView.atYEnd
}
ToolButton {
text: MaterialIcons.assignment
ToolTip.text: "Copy"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
onClicked: copySubMenu.open()
Menu {
id: copySubMenu
x: parent.width
MenuItem {
text: "Copy Visible Text"
onTriggered: {
var t = "";
for(var i = textView.firstVisibleIndex(); i < textView.lastVisibleIndex(); ++i)
t += textView.model[i] + "\n";
Clipboard.setText(t);
}
}
MenuItem {
text: "Copy All"
onTriggered: {
Clipboard.setText(textView.text);
}
}
}
}
ToolButton {
text: MaterialIcons.open_in_new
ToolTip.text: "Open Externally"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
enabled: root.source !== ""
onClicked: Qt.openUrlExternally(root.source)
}
}
}
MouseArea {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.margins: 4
ListView {
id: textView
property string text
// model consists in text split by line
model: textView.text.split("\n")
visible: text != ""
anchors.fill: parent
clip: true
focus: true
// custom key navigation handling
keyNavigationEnabled: false
highlightFollowsCurrentItem: true
highlightMoveDuration: 0
Keys.onPressed: {
switch(event.key)
{
case Qt.Key_Home:
textView.positionViewAtBeginning();
break;
case Qt.Key_End:
textView.positionViewAtEnd();
break;
case Qt.Key_Up:
currentIndex = firstVisibleIndex();
decrementCurrentIndex();
break;
case Qt.Key_Down:
currentIndex = lastVisibleIndex();
incrementCurrentIndex();
break;
case Qt.Key_PageUp:
textView.positionViewAtIndex(firstVisibleIndex(), ListView.End);
break;
case Qt.Key_PageDown:
textView.positionViewAtIndex(lastVisibleIndex(), ListView.Beginning);
break;
}
}
function setText(value, keepPosition) {
// store cursor position and content position
var topIndex = firstVisibleIndex();
var scrollToBottom = atYEnd;
// replace text
text = value;
if(scrollToBottom)
positionViewAtEnd();
else if(topIndex !== firstVisibleIndex() && keepPosition)
positionViewAtIndex(topIndex, ListView.Beginning);
}
function firstVisibleIndex() {
return indexAt(contentX, contentY);
}
function lastVisibleIndex() {
return indexAt(contentX, contentY + height - 2);
}
ScrollBar.vertical: ScrollBar {
id: vScrollBar
minimumSize: 0.05
}
ScrollBar.horizontal: ScrollBar { minimumSize: 0.1 }
// TextMetrics for line numbers column
TextMetrics {
id: lineMetrics
font.family: "Monospace, Consolas, Monaco"
text: textView.count * 10
}
// TextMetrics for textual progress bar
TextMetrics {
id: progressMetrics
// total number of character in textual progress bar
property int count: 51
property string character: '*'
text: character.repeat(count)
}
delegate: RowLayout {
width: textView.width
spacing: 6
// Line number
Label {
text: index + 1
Layout.minimumWidth: lineMetrics.width
rightPadding: 2
enabled: false
Layout.fillHeight: true
horizontalAlignment: Text.AlignRight
}
Loader {
id: delegateLoader
Layout.fillWidth: true
// default line delegate
sourceComponent: line_component
// line delegate selector based on content
StateGroup {
states: [
State {
name: "progressBar"
// detect textual progressbar (non empty line with only progressbar character)
when: modelData.trim().length
&& modelData.split(progressMetrics.character).length - 1 === modelData.trim().length
PropertyChanges {
target: delegateLoader
sourceComponent: progressBar_component
}
}
]
}
// ProgressBar delegate
Component {
id: progressBar_component
Item {
Layout.fillWidth: true
implicitHeight: progressMetrics.height
ProgressBar {
width: progressMetrics.width
height: parent.height - 2
anchors.verticalCenter: parent.verticalCenter
from: 0
to: progressMetrics.count
value: modelData.length
}
}
}
// Default line delegate
Component {
id: line_component
TextInput {
wrapMode: Text.WrapAnywhere
text: modelData
font.family: "Monospace, Consolas, Monaco"
padding: 0
selectByMouse: true
readOnly: true
selectionColor: Colors.sysPalette.highlight
persistentSelection: false
Keys.forwardTo: [textView]
color: {
// color line according to log level
if(text.indexOf("[warning]") >= 0)
return Colors.orange;
else if(text.indexOf("[error]") >= 0)
return Colors.red;
return palette.text;
}
}
}
}
}
}
RowLayout {
anchors.fill: parent
anchors.rightMargin: vScrollBar.width
z: -1
Item {
Layout.preferredWidth: lineMetrics.width
Layout.fillHeight: true
}
// IBeamCursor shape overlay
MouseArea {
Layout.fillWidth: true
Layout.fillHeight: true
cursorShape: Qt.IBeamCursor
}
}
// File loading indicator
BusyIndicator {
Component.onCompleted: running = Qt.binding(function() { return root.loading })
padding: 0
anchors.right: parent.right
anchors.bottom: parent.bottom
implicitWidth: 16
implicitHeight: 16
}
}
}
// Auto-reload current file timer
Timer {
running: root.autoReload
interval: root.autoReloadInterval
repeat: true
// reload file on start and stop
onRunningChanged: loadSource(true)
onTriggered: loadSource(true)
}
// Load current source file and update ListView's model
function loadSource(keepPosition = false)
{
if(!visible)
return;
loading = true;
var xhr = new XMLHttpRequest;
xhr.open("GET", root.source);
xhr.onload = function() {
// - can't rely on 'Last-Modified' header response to verify
// that file has changed on disk (not always up-to-date)
// - instead, let QML engine evaluate whether 'text' property value has changed
textView.setText(xhr.status === 200 ? xhr.responseText : "", keepPosition);
loading = false;
};
xhr.send();
}
}

View file

@ -5,3 +5,4 @@ Group 1.0 Group.qml
MessageDialog 1.0 MessageDialog.qml
Panel 1.0 Panel.qml
SearchBar 1.0 SearchBar.qml
TextFileViewer 1.0 TextFileViewer.qml

View file

@ -96,7 +96,7 @@ Panel {
}
Label {
color: Qt.lighter(palette.mid, 1.2)
text: "Select a Node to edit its Attributes"
text: "Select a Node to access its Details"
}
}
}
@ -128,12 +128,17 @@ Panel {
node: root.node
}
}
}
}
}
TabBar {
id: tabBar
Layout.fillWidth: true
width: childrenRect.width
position: TabBar.Footer
currentIndex: 1
TabButton {
text: "Attributes"
width: implicitWidth
@ -150,6 +155,3 @@ Panel {
}
}
}
}
}
}

View file

@ -1,9 +1,9 @@
import QtQuick 2.9
import QtQuick 2.12
import QtQuick.Controls 2.3
import QtQuick.Controls 1.4 as Controls1 // SplitView
import QtQuick.Layouts 1.3
import MaterialIcons 2.2
import Utils 1.0
import Controls 1.0
import "common.js" as Common
@ -85,17 +85,13 @@ FocusScope {
id: fileSelector
Layout.fillWidth: true
property string currentFile: chunksLV.currentChunk ? chunksLV.currentChunk[currentItem.fileProperty] : ""
property string lastLoadedFile
property date lastModTime
onCurrentFileChanged: if(visible) loadCurrentFile()
onVisibleChanged: if(visible) loadCurrentFile()
TabButton {
property string fileProperty: "logFile"
text: "Log"
text: "Output"
padding: 4
}
TabButton {
property string fileProperty: "statisticsFile"
text: "Statistics"
@ -108,152 +104,12 @@ FocusScope {
}
}
RowLayout {
Layout.fillHeight: true
Layout.fillWidth: true
spacing: 0
Pane {
id: tb
Layout.alignment: Qt.AlignTop
Layout.fillHeight: true
padding: 0
background: Rectangle { color: Qt.darker(activePalette.window, 1.2) }
Column {
height: parent.height
ToolButton {
text: MaterialIcons.refresh
ToolTip.text: "Refresh"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
onClicked: loadCurrentFile(false)
}
ToolButton {
id: autoRefresh
text: MaterialIcons.timer
ToolTip.text: "Auto-Refresh when Running"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
checked: true
checkable: true
}
ToolButton {
text: MaterialIcons.vertical_align_top
ToolTip.text: "Scroll to Top"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
onClicked: logArea.cursorPosition = 0
}
ToolButton {
text: MaterialIcons.vertical_align_bottom
ToolTip.text: "Scroll to Bottom"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
onClicked: logArea.cursorPosition = logArea.length
}
ToolButton {
id: autoScroll
text: MaterialIcons.system_update_alt
ToolTip.text: "Auto-Scroll to Bottom"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
checkable: true
checked: true
}
ToolButton {
text: MaterialIcons.open_in_new
ToolTip.text: "Open Externally"
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
enabled: fileSelector.currentFile != ""
onClicked: Qt.openUrlExternally(Filepath.stringToUrl(fileSelector.currentFile))
}
}
}
// Log display
ScrollView {
id: logScrollView
TextFileViewer {
Layout.fillWidth: true
Layout.fillHeight: true
TextArea {
id: logArea
selectByMouse: true
selectByKeyboard: true
persistentSelection: true
font.family: "Monospace, Consolas, Monaco"
autoReload: chunksLV.currentChunk !== undefined && chunksLV.currentChunk.statusName === "RUNNING"
source: Filepath.stringToUrl(fileSelector.currentFile)
}
}
}
}
}
// Auto-reload current file if NodeChunk is being computed
Timer {
running: autoRefresh.checked && chunksLV.currentChunk != undefined && chunksLV.currentChunk.statusName === "RUNNING"
interval: 2000
repeat: true
// reload file on start and stop
onRunningChanged: loadCurrentFile(true)
onTriggered: loadCurrentFile(true)
}
function loadCurrentFile(keepCursorPosition)
{
if(keepCursorPosition == undefined)
keepCursorPosition = false
var xhr = new XMLHttpRequest;
xhr.open("GET", Filepath.stringToUrl(fileSelector.currentFile));
xhr.onreadystatechange = function() {
if(xhr.readyState == XMLHttpRequest.HEADERS_RECEIVED)
{
// if the file is already open
// check last modification date
var lastMod = new Date(xhr.getResponseHeader("Last-Modified"));
if(fileSelector.lastLoadedFile == fileSelector.currentFile
&& lastMod.getTime() == fileSelector.lastModTime.getTime() )
{
// file has not changed, don't reload it
xhr.doLoad = false;
return
}
// file is different or last modification time has changed
fileSelector.lastModTime = lastMod
xhr.doLoad = true
}
if (xhr.readyState == XMLHttpRequest.DONE) {
// store lastLoadedFile url
fileSelector.lastLoadedFile = fileSelector.currentFile
// if responseText should not be loaded
if(!xhr.doLoad)
{
// file could not be opened, reset text and lastModTime
if(xhr.status == 0)
{
fileSelector.lastModTime = new Date()
logArea.text = ''
}
return;
}
// store cursor position and content position
var cursorPosition = logArea.cursorPosition;
var contentY = logScrollView.ScrollBar.vertical.position;
// replace text
logArea.text = xhr.responseText;
if(autoScroll.checked)
{
// Reset cursor position to trigger scroll to bottom
logArea.cursorPosition = 0;
logArea.cursorPosition = logArea.length;
}
else if(keepCursorPosition)
{
if(cursorPosition)
logArea.cursorPosition = cursorPosition;
logScrollView.ScrollBar.vertical.position = contentY
}
}
};
xhr.send();
}
}