Meshroom/meshroom/ui/qml/Controls/TextFileViewer.qml
2023-07-03 12:11:29 +02:00

404 lines
15 KiB
QML

import QtQuick 2.15
import QtQuick.Controls 2.15
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()
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: {
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: 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
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.getTimeStr(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});
}
}
}