Meshroom/meshroom/ui/qml/Controls/TextFileViewer.qml

398 lines
15 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
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()
onAutoReloadChanged: 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
spacing: 1
MaterialToolButton {
text: MaterialIcons.refresh
ToolTip.text: "Reload"
onClicked: loadSource()
}
MaterialToolButton {
text: MaterialIcons.vertical_align_top
ToolTip.text: "Scroll to Top"
onClicked: textView.positionViewAtBeginning()
}
MaterialToolButton {
id: autoscroll
text: MaterialIcons.vertical_align_bottom
ToolTip.text: "Scroll to Bottom"
onClicked: textView.positionViewAtEnd()
checkable: false
checked: textView.atYEnd
}
MaterialToolButton {
text: MaterialIcons.assignment
ToolTip.text: "Copy"
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.get(i).line + "\n"
Clipboard.setText(t)
}
}
MenuItem {
text: "Copy All"
onTriggered: {
Clipboard.setText(textView.text)
}
}
}
}
MaterialToolButton {
text: MaterialIcons.open_in_new
ToolTip.text: "Open Externally"
enabled: root.source !== ""
onClicked: Qt.openUrlExternally(root.source)
}
}
}
MouseArea {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.margins: 4
ListView {
id: textView
property string text
ListModel {
id: logLinesModel
}
onTextChanged: {
updateLogLinesModel(logLinesModel, text);
}
model: logLinesModel
visible: text != ""
anchors.fill: parent
clip: true
focus: true
// Custom key navigation handling
keyNavigationEnabled: false
highlightFollowsCurrentItem: true
highlightMoveDuration: 0
Keys.onPressed: function(event) {
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) {
// Store current first index
var topIndex = firstVisibleIndex()
// Store whether autoscroll to bottom is active
var scrollToBottom = atYEnd && autoscroll.checked
// Replace text
text = value
// Restore content position by either:
// - autoscrolling to bottom
if (scrollToBottom)
positionViewAtEnd()
// - setting first visible index back (when possible)
else if (topIndex !== firstVisibleIndex())
positionViewAtIndex(Math.min(topIndex, count - 1), ListView.Beginning)
}
function firstVisibleIndex() {
return indexAt(contentX, contentY)
}
function lastVisibleIndex() {
return indexAt(contentX, contentY + height - 2)
}
ScrollBar.vertical: MScrollBar { id: vScrollBar }
ScrollBar.horizontal: MScrollBar {}
// 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
property var logLine: textView.model.get(index) ? textView.model.get(index) : {"line": "", "duration": -1}
Item {
Layout.minimumWidth: childrenRect.width
Layout.fillHeight: true
RowLayout {
height: parent.height
// Colored marker to quickly indicate duration
Rectangle {
width: 4
Layout.fillHeight: true
color: Colors.durationColor(logLine.duration)
}
// Line number
Label {
text: index + 1
Layout.minimumWidth: lineMetrics.width
rightPadding: 6
Layout.fillHeight: true
horizontalAlignment: Text.AlignRight
color: "#CCCCCC"
}
}
// Display a tooltip with the duration when hovered
MouseArea {
id: mouseArea
hoverEnabled: true
anchors.fill: parent
}
enabled: logLine.duration > 0
ToolTip.text: "Elapsed time: " + Format.sec2timeStr(logLine.duration)
ToolTip.visible: mouseArea.containsMouse
}
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: logLine.line.trim().length
&& logLine.line.split(progressMetrics.character).length - 1 === logLine.line.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: logLine.line.length
}
}
}
// Default line delegate
Component {
id: line_component
TextInput {
wrapMode: Text.WrapAnywhere
text: logLine.line
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 {
id: reloadTimer
running: root.autoReload
interval: root.autoReloadInterval
repeat: false // timer is restarted in request's callback (see loadSource)
onTriggered: loadSource()
}
// Load current source file and update ListView's model
function loadSource() {
if (!visible)
return
loading = true
var xhr = new XMLHttpRequest
xhr.open("GET", root.source)
xhr.onreadystatechange = 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
if (xhr.readyState === XMLHttpRequest.DONE) {
textView.setText(xhr.status === 200 ? xhr.responseText : "")
loading = false
// Re-trigger reload source file
if (autoReload)
reloadTimer.restart()
}
}
xhr.send()
}
// Parse log-line to see if it contains a time indicator
// and if yes then turn it into a time value (in seconds)
function getLogLineTime(line) {
const regex = /[0-9]{2}:[0-9]{2}:[0-9]{2}/
const found = line.match(regex)
if (found && found.length > 0) {
let hh = parseInt(found[0].substring(0, 2))
let mm = parseInt(found[0].substring(3, 5))
let ss = parseInt(found[0].substring(6, 8))
let time = ss + 60 * mm + 3600 * hh;
if (!isNaN(time)) {
return time
}
}
return -1
}
// Update a log-lines ListModel from a log-text by filling it with elements containing:
// - a log-line (string)
// - the elapsed time since the last log-line containing a time value and this one (if it also contains a time value)
function updateLogLinesModel(llm, text) {
llm.clear()
const lines = text.split('\n')
const times = lines.map(line => getLogLineTime(line))
let prev_idx = -1
for (let i = 0; i < lines.length; i++) {
let delta = -1
if (times[i] >= 0) {
if (prev_idx >= 0) {
delta = times[i]-times[prev_idx]
}
prev_idx = i
}
llm.append({"line": lines[i], "duration": delta})
}
}
}