Implement event model in C++

Filtering events in JS is too slow with >20,000 events. This moves the
event data model into C++.
This commit is contained in:
Timotej Lazar 2021-09-01 17:13:51 +02:00
parent e9b70c585c
commit cb76fedcbc
No known key found for this signature in database
GPG key ID: B6F38793D143456F
14 changed files with 375 additions and 342 deletions

View file

@ -7,61 +7,77 @@ import QtQuick.Layouts 1.6
import 'util.js' as Util
// This is the delegate for event list items.
Pane {
id: control
ItemDelegate {
required property var model
required property int index
required property int time
property int time
property alias tag: tag.text
property alias fields: inputs.model
property alias fields: inputs.model // field definitions
property bool editing: false
signal remove
clip: true
height: visible ? stuff.height + 2*padding : 0 // TODO fix filtering and remove this
padding: 2
// set to current value or default
background: Rectangle {
anchors.fill: parent
color: highlighted ? Util.alphize(border.color, 0.1) :
(index % 2 === 0 ? palette.base : palette.alternateBase)
border {
color: editing ? palette.highlight : palette.dark
width: highlighted ? 1 : 0
}
radius: border.width
}
// Set inputs to current model values.
function reset() {
for (var i = 0; i < fields.count; i++) {
for (var i = 0; i < fields.length; i++) {
const child = inputs.itemAt(i)
if (child && child.item)
child.item.set(fields.get(i).value)
child.item.set(model.values[fields[i].name])
}
}
// Store current inputs in model.
function store() {
for (var i = 0; i < fields.count; i++)
fields.setProperty(i, 'value', inputs.itemAt(i).item.value)
var values = {}
for (var i = 0; i < fields.length; i++)
values[fields[i].name] = inputs.itemAt(i).item.value
model.values = values
}
// Pass keys to each field input in order.
Component.onCompleted: reset()
onEditingChanged: {
if (editing)
forceActiveFocus()
}
// Try passing key to each field input in order.
Keys.forwardTo: Array.from({ length: inputs.count }, (_, i) => inputs.itemAt(i).item)
Behavior on height { NumberAnimation { duration: 50 } }
ColumnLayout {
id: stuff
contentItem: ColumnLayout {
anchors { left: parent.left; right: parent.right; margins: 5 }
// Event time, tag and summary.
RowLayout {
Label {
text: new Date(time).toISOString().substr(12, 9)
text: new Date(model.time).toISOString().substr(12, 9)
font.pixelSize: 10
Layout.alignment: Qt.AlignBaseline
}
Label {
id: tag
text: model.tag
font.weight: Font.DemiBold
Layout.alignment: Qt.AlignBaseline
}
Label {
text: {
var str = ''
for (var i = 0; i < fields.count; i++) {
const field = fields.get(i)
if (field.value && field.type !== 'TextArea')
str += (field.type === 'Bool' ? field.name : field.value) + ' '
for (var i = 0; i < inputs.count; i++) {
const field = inputs.model[i]
const value = inputs.itemAt(i).item.value
if (value && field.type !== 'TextArea')
str += (field.type === 'Bool' ? field.name : value) + ' '
}
return str
}
@ -72,10 +88,8 @@ Pane {
}
}
// Eventspecific inputs.
// Eventspecific input fields.
GridLayout {
id: fieldset
flow: GridLayout.TopToBottom
rows: inputs.count
@ -86,7 +100,7 @@ Pane {
Repeater {
model: inputs.model
delegate: Label {
text: Util.addShortcut(model.name, model.key)
text: Util.addShortcut(modelData.name, modelData.key)
Layout.alignment: Qt.AlignRight
}
}
@ -95,12 +109,12 @@ Pane {
Repeater {
id: inputs
delegate: Loader {
source: 'qrc:/Fields/' + model.type + '.qml'
source: 'qrc:/Fields/' + modelData.type + '.qml'
Layout.fillHeight: true
Layout.fillWidth: true
Binding {
target: item; property: 'definition'
value: model
target: item; property: 'model'
value: modelData
}
}
}

View file

@ -3,139 +3,35 @@
import QtQuick 2.12
import QtQuick.Controls 2.13
import QtQuick.Layouts 1.6
import QtQml.Models 2.1
import 'util.js' as Util
ListView {
id: control
required property var tags // tag definitions
property bool editing: false
property var tags: []
signal changed
clip: true
focus: true
keyNavigationEnabled: true
highlightMoveDuration: 0
highlightResizeDuration: 0
ScrollBar.vertical: ScrollBar { anchors.right: parent.right }
// Create a new blank event, insert it and start editing.
function create(time, tag, fields) {
const index = Util.find(list, 'time', time)
list.insert(index, {
'time': time,
'tag': tag,
'fields': fields,
})
currentIndex = index
if (fields.length > 0)
editing = true
changed()
}
function clear() {
list.clear()
}
function load(json) {
// Return list of fields for the given tag.
function getFields(name) {
for (var i = 0; i < tags.length; i++)
if (tags[i].tag === name)
return tags[i].fields
return []
}
for (var i = 0; i < json.length; i++) {
const event = json[i]
var fields = getFields(event.tag)
for (var j = 0; j < fields.length; j++)
fields[j].value = event.fields[fields[j].name]
list.append({ 'time': event.time, 'tag': event.tag, 'fields': fields })
}
forceActiveFocus()
}
function save() {
var data = []
for (var i = 0; i < list.count; i++) {
const event = list.get(i)
var fields = {}
for (var j = 0; j < event.fields.count; j++) {
const field = event.fields.get(j)
fields[field.name] = field.value
}
data.push({ 'time': event.time, 'tag': event.tag, 'fields': fields })
}
return data
}
onCurrentIndexChanged: editing = false
Keys.onPressed: {
switch (event.key) {
case Qt.Key_Enter:
case Qt.Key_Return:
if (editing) {
currentItem.store()
changed()
editing = false
} else {
if (currentItem.fields.count > 0)
editing = true
}
break
case Qt.Key_Escape:
if (editing) {
currentItem.reset()
editing = false
}
break
case Qt.Key_Delete:
editing = false
if (currentIndex >= 0 && currentIndex < list.count) {
list.remove(currentIndex)
changed()
}
break
case Qt.Key_Tab:
case Qt.Key_Backtab:
// swallow tabs so we dont lose focus when editing
break
default:
return
}
event.accepted = true
}
model: ListModel {
id: list
dynamicRoles: true
}
ScrollBar.vertical: ScrollBar { anchors.right: parent.right }
delegate: Event {
id: item
time: model.time
tag: model.tag
fields: model.fields
// If field definitions are missing for this events tag, use
// Text for all field types unless where the value is bool.
fields: tags[model.tag] ? tags[model.tag].fields :
Object.entries(model.values).map(value => ({
'name': value[0],
'type': typeof(value[1]) === 'boolean' ? 'Bool' : 'Text',
}))
width: control.width
editing: control.editing && ListView.isCurrentItem
background: Rectangle {
anchors.fill: parent
color: border.width > 0 ? Util.alphize(border.color, 0.1) :
(index % 2 === 0 ? palette.base : palette.alternateBase)
border {
color: editing ? palette.highlight : palette.dark
width: item.ListView.isCurrentItem ? 1 : 0
}
radius: border.width
}
highlighted: ListView.isCurrentItem
Connections {
enabled: ListView.currentIndex === index
@ -143,13 +39,10 @@ ListView {
control.positionViewAtIndex(index, ListView.Contain)
}
}
onEditingChanged: {
reset()
if (editing)
forceActiveFocus()
}
onRemove: {
list.remove(ObjectModel.index)
onClicked: {
control.currentIndex = index
control.forceActiveFocus()
}
}
}

View file

@ -4,14 +4,13 @@ import QtQuick 2.12
import QtQuick.Controls 2.13
Row {
id: control
width: parent.width
property var definition
property var model
property alias value: input.checked
Keys.onPressed: {
if (event.text === definition.key) {
if (event.text === model.key) {
value = !value
event.accepted = true
}

View file

@ -8,13 +8,13 @@ import '../util.js' as Util
Column {
id: control
property var definition
property var model
property int index: -1
readonly property string value: index >= 0 ? definition.values.get(index).name : ''
readonly property string value: index >= 0 ? model.values[index].name : ''
function set(val) {
for (var i = 0; i < definition.values.count; i++) {
if (definition.values.get(i).name === val) {
for (var i = 0; i < model.values.length; i++) {
if (model.values[i].name === val) {
index = i
return true
}
@ -23,8 +23,8 @@ Column {
}
Keys.onPressed: {
for (var i = 0; i < definition.values.count; i++) {
if (definition.values.get(i).key === event.text) {
for (var i = 0; i < model.values.length; i++) {
if (model.values[i].key === event.text) {
index = (index === i ? -1 : i)
event.accepted = true
break
@ -39,7 +39,7 @@ Column {
ButtonGroup { id: buttons }
Repeater {
model: definition.values
model: control.model.values
delegate: Button {
ButtonGroup.group: buttons
checkable: true
@ -52,7 +52,7 @@ Column {
rightPadding: leftPadding
onClicked: control.index = (control.index === index ? -1 : index)
text: Util.addShortcut(name, key)
text: Util.addShortcut(modelData.name, modelData.key)
}
}
}

View file

@ -6,11 +6,11 @@ import QtQuick.Controls 2.13
Label {
id: control
property var definition
property var model
property alias value: control.text
Keys.onPressed: {
if (event.text === definition.key) {
if (event.text === model.key) {
popup.open()
event.accepted = true
}
@ -23,8 +23,8 @@ Label {
Popup {
id: popup
width: control.width
height: control.height
width: parent.width
height: parent.height
padding: 0
onOpened: {

View file

@ -6,11 +6,11 @@ import QtQuick.Controls 2.13
Label {
id: control
property var definition
property var model
property alias value: control.text
Keys.onPressed: {
if (event.text === definition.key) {
if (event.text === model.key) {
popup.open()
event.accepted = true
}
@ -23,7 +23,7 @@ Label {
Popup {
id: popup
width: control.width
width: parent.width
height: input.height
padding: 0

View file

@ -1,34 +0,0 @@
// SPDX-License-Identifier: Unlicense
import QtQuick 2.12
import QtQuick.Controls 2.13
import QtQuick.Layouts 1.6
GridLayout {
property var tags: []
function check(item) {
if (item.tag === tags.currentText)
return true
return false
}
signal changed
columns: 2
Label { text: qsTr('Tag') }
ComboBox {
model: tags
textRole: 'tag'
Layout.fillWidth: true
onCurrentTextChanged: changed()
}
Label {
text: qsTr('Filters are not implemented yet! 😊')
wrapMode: Text.Wrap
Layout.fillWidth: true
Layout.columnSpan: 2
}
}

View file

@ -5,48 +5,30 @@ import QtQuick.Controls 2.13
import QtQuick.Layouts 1.6
import Qt.labs.platform 1.1
import fuzbal 1
Page {
id: control
property bool modified: false
property Video video
function clear() {
description.clear()
events.clear()
}
function save() {
modified = false
return {
meta: {
version: Qt.application.version,
video: video.source.toString(),
description: description.text
},
tags: tags.model,
events: events.save()
}
}
function load(data) {
if (data.meta.description !== undefined)
description.text = data.meta.description
if (data.tags !== undefined)
tags.model = data.tags
events.load(data.events)
modified = false
EventList {
id: eventList
onDataChanged: modified = true
onRowsInserted: modified = true
onRowsRemoved: modified = true
}
FileDialog {
id: videoDialog
title: qsTr('Open video')
onAccepted: {
clear()
video.source = currentFile
const events = io.read(video.source+'.events')
if (events)
load(JSON.parse(events))
const json = JSON.parse(io.read(currentFile+'.events') || '{}')
eventList.load(json)
description.text = json['description'] || ''
modified = false
}
}
@ -54,7 +36,7 @@ Page {
id: tagsDialog
title: qsTr('Load tags')
nameFilters: [qsTr('JSON files (*.json)'), qsTr('All files (*)')]
onAccepted: tags.model = JSON.parse(io.read(currentFile))
onAccepted: eventList.load({ 'tags': JSON.parse(io.read(currentFile)) })
}
Keys.forwardTo: [tags, video]
@ -78,7 +60,14 @@ Page {
}
ToolButton {
action: Action {
onTriggered: io.write(video.source+'.events', JSON.stringify(save()))
onTriggered: {
var json = eventList.save()
json['description'] = description.text
json['video'] = video.source
json['version'] = Qt.application.version
io.write(video.source+'.events', JSON.stringify(json))
modified = false
}
shortcut: StandardKey.Save
icon.name: 'document-save'
enabled: video.loaded && control.modified
@ -144,100 +133,92 @@ Page {
anchors.fill: parent
focus: true
tags: tags.model
model: eventList
tags: eventList.tags
onEditingChanged: video.pause(editing)
onChanged: modified = true
onCurrentItemChanged: {
if (currentItem)
video.seek(currentItem.time)
}
MouseArea {
anchors.fill: parent
enabled: !parent.editing
onPressed: {
const index = events.indexAt(mouse.x, mouse.y)
if (index !== -1) {
events.currentIndex = index
video.seek(events.itemAtIndex(index).time)
Keys.onPressed: {
switch (event.key) {
case Qt.Key_Home:
currentIndex = 0
break
case Qt.Key_End:
currentIndex = count-1
break
case Qt.Key_Enter:
case Qt.Key_Return:
if (editing) {
currentItem.store()
editing = false
} else {
if (currentItem.fields.length > 0)
editing = true
}
forceActiveFocus()
break
case Qt.Key_Escape:
if (editing) {
currentItem.reset()
editing = false
}
break
case Qt.Key_Delete:
editing = false
eventList.removeRows(currentIndex)
break
case Qt.Key_Tab:
case Qt.Key_Backtab:
// swallow tabs so we dont lose focus when editing
break
default:
return
}
event.accepted = true
}
}
}
}
Page {
// Tag list.
Frame {
Layout.fillWidth: true
Layout.fillHeight: false
padding: 5
StackLayout {
currentIndex: bar.currentIndex
implicitHeight: children[currentIndex].implicitHeight
ColumnLayout {
width: parent.width
spacing: 0
Frame {
padding: 5
enabled: visible
Layout.fillWidth: true
ColumnLayout {
width: parent.width
spacing: 0
RowLayout {
Label {
text: qsTr('Tags')
Layout.fillWidth: true
}
ToolButton {
icon.name: 'document-open'
Layout.alignment: Qt.AlignTop
onClicked: tagsDialog.open()
focusPolicy:Qt.NoFocus
}
}
Tags {
id: tags
model: JSON.parse(io.read('qrc:/tags.json'))
enabled: video.loaded && !events.editing
onClicked: events.create(video.time, tag, fields)
Layout.fillWidth: true
}
RowLayout {
Label {
text: qsTr('Tags')
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
}
ToolButton {
icon.name: 'document-open'
Layout.alignment: Qt.AlignVCenter
onClicked: tagsDialog.open()
focusPolicy:Qt.NoFocus
}
}
Frame {
padding: 5
enabled: visible
Tags {
id: tags
model: eventList.tagsOrder.map(tag => eventList.tags[tag])
enabled: video.loaded && !events.editing
onClicked: {
events.currentIndex = eventList.insert(video.time)
const event = events.currentItem
event.model.tag = tag
if (event.fields.length > 0)
events.editing = true
}
Layout.fillWidth: true
Filter {
id: filter
tags: tags.model
width: parent.width
onChanged: print('filter changed')
}
}
}
footer: TabBar {
id: bar
Layout.fillWidth: true
ActionGroup { id: tabActions }
Repeater {
model: [
{ text: qsTr('&Annotate'), shortcut: qsTr('Alt+A') },
{ text: qsTr('&Filter'), shortcut: qsTr('Alt+F') }
]
delegate: TabButton {
action: Action {
ActionGroup.group: tabActions
shortcut: modelData.shortcut
}
text: modelData.text
focusPolicy: Qt.NoFocus
padding: 5
onClicked: TabBar.tabBar.setCurrentIndex(index)
}
}
}
}

View file

@ -2,44 +2,34 @@
import QtQuick 2.12
import QtQuick.Controls 2.13
import QtQuick.Layouts 1.6
import 'util.js' as Util
// Tag list.
Page {
Flow {
id: control
property alias model: tags.model
property alias model: buttons.model
signal clicked(string tag, var fields)
signal clicked(string tag)
// Try passing key to each field input in order.
Keys.enabled: enabled
Keys.onPressed: {
for (var i = 0; i < model.length; i++) {
const tag = model[i]
if (tag.key === event.text) {
clicked(tag.tag, tag.fields)
return
}
}
event.accepted = false
}
Keys.forwardTo: Array.from({ length: buttons.count }, (_, i) => buttons.itemAt(i))
RowLayout {
width: parent.width
spacing: 5
Flow {
spacing: 5
Layout.fillWidth: true
Repeater {
id: tags
delegate: Button {
text: Util.addShortcut(modelData.tag, modelData.key)
onClicked: control.clicked(modelData.tag, modelData.fields)
focusPolicy: Qt.NoFocus
implicitWidth: implicitContentWidth + 2*padding
Repeater {
id: buttons
delegate: Button {
text: Util.addShortcut(modelData.tag, modelData.key)
focusPolicy: Qt.NoFocus
implicitWidth: implicitContentWidth + 2*padding
onClicked: control.clicked(modelData.tag)
Keys.onPressed: {
if (event.text === modelData.key) {
clicked()
event.accepted = true
}
}
}

141
event_list.cpp Normal file
View file

@ -0,0 +1,141 @@
#include "event_list.h"
#include <QJsonArray>
#include <QJSValue>
Qt::ItemFlags EventList::flags(const QModelIndex&) const
{
return Qt::ItemIsSelectable | Qt::ItemIsEditable | Qt::ItemIsEnabled | Qt::ItemNeverHasChildren;
}
QHash<int, QByteArray> EventList::roleNames() const
{
static const QHash<int, QByteArray> roles{
{Role::Time, "time"},
{Role::Tag, "tag"},
{Role::Values, "values"},
};
return roles;
}
int EventList::rowCount(const QModelIndex&) const
{
return events.size();
}
QVariant EventList::data(const QModelIndex& index, int role) const
{
const auto& event = events[index.row()];
switch (role) {
case Role::Time:
return event.time;
case Role::Tag:
return event.tag;
case Role::Values:
return event.values;
default:
return {};
}
}
bool EventList::setData(const QModelIndex& index, const QVariant& value, int role)
{
auto& event = events[index.row()];
switch (role) {
case Role::Time:
event.time = value.toLongLong();
break;
case Role::Tag:
event.tag = value.toString();
break;
case Role::Values:
event.values = value.value<QJSValue>().toVariant().toMap();
break;
default:
return false;
}
emit dataChanged(index, index, {role});
return true;
}
int EventList::insert(const int time)
{
int row = time == -1 ? rowCount() : find(time);
beginInsertRows(QModelIndex{}, row, row);
events.insert(row, {time});
endInsertRows();
return row;
}
bool EventList::removeRows(int row, int count, const QModelIndex&)
{
beginRemoveRows({}, row, row + count - 1);
while (row < events.size() && count-- > 0)
events.removeAt(row);
endRemoveRows();
return count == -1;
}
void EventList::load(const QJsonObject& json)
{
const auto& jsonTags = json["tags"].toArray();
if (!jsonTags.isEmpty()) {
tags = {};
tagsOrder.clear();
for (int i = 0; i < jsonTags.size(); i++) {
const auto name = jsonTags[i]["tag"].toString();
tags[name] = jsonTags[i].toObject();
tagsOrder.append(name);
}
emit tagsChanged();
}
const auto& jsonEvents = json["events"].toArray();
if (!jsonEvents.isEmpty()) {
beginResetModel();
events.clear();
for (int i = 0; i < jsonEvents.size(); i++) {
auto event = jsonEvents[i].toObject().toVariantMap();
events.append({
event["time"].toLongLong(),
event["tag"].toString(),
event[event.contains("values") ? "values" : "fields"].toMap(),
});
}
endResetModel();
}
}
QJsonObject EventList::save() const
{
QJsonArray jsonEvents;
for (const auto& event : events) {
jsonEvents.append(QJsonObject{
{"time", event.time},
{"tag", event.tag},
{"values", QJsonObject::fromVariantMap(event.values)}
});
}
QJsonArray jsonTags;
for (int i = 0; i < tagsOrder.size(); i++)
jsonTags.append(tags[tagsOrder[i]].toObject());
return {{"tags", jsonTags}, {"events", jsonEvents}};
}
// Return the index of the last event not later than given time.
// Assumes events are sorted by time.
int EventList::find(long long time) const
{
int low = 0;
int high = events.size() - 1;
while (low <= high) {
int mid = (low + high) / 2;
if (events[mid].time <= time)
low = mid + 1;
else
high = mid - 1;
}
return low;
}

46
event_list.h Normal file
View file

@ -0,0 +1,46 @@
#ifndef EVENT_LIST_H
#define EVENT_LIST_H
#include <QAbstractListModel>
#include <QJsonObject>
#include <QStringList>
#include <qqml.h>
class EventList : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(QStringList tagsOrder MEMBER tagsOrder NOTIFY tagsChanged)
Q_PROPERTY(QJsonObject tags MEMBER tags NOTIFY tagsChanged)
QML_ELEMENT
public:
Qt::ItemFlags flags(const QModelIndex& index) const;
QHash<int, QByteArray> roleNames() const;
int rowCount(const QModelIndex& parent = {}) const;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const;
bool setData(const QModelIndex& index, const QVariant& value, int role);
public slots:
int insert(const int time = -1);
bool removeRows(int row, int count = 1, const QModelIndex &parent = {});
void load(const QJsonObject& json);
QJsonObject save() const;
signals:
void tagsChanged();
private:
struct Event {
long long time;
QString tag{};
QVariantMap values{};
};
enum Role { Time = Qt::UserRole + 1, Tag, Values };
QStringList tagsOrder;
QJsonObject tags;
QList<Event> events;
int find(long long time) const;
};
#endif

View file

@ -1,11 +1,15 @@
# SPDX-License-Identifier: Unlicense
QT += multimedia qml quick quickcontrols2 svg widgets
CONFIG += embed_translations lrelease
CONFIG += c++1z embed_translations lrelease qmltypes
DEFINES += GIT_VERSION=\\\"$$system(git -C "$$_PRO_FILE_PWD_" describe --always --tags)\\\"
SOURCES += main.cpp
HEADERS += io.h
QML_IMPORT_NAME = fuzbal
QML_IMPORT_MAJOR_VERSION = 1
SOURCES += event_list.cpp main.cpp
HEADERS += event_list.h io.h
RESOURCES += main.qrc icons.qrc
TRANSLATIONS += translations/fuzbal_sl.ts

View file

@ -14,7 +14,7 @@
#include <QTranslator>
#include <QtDebug>
#include <io.h>
#include "io.h"
int main(int argc, char *argv[])
try {

View file

@ -11,7 +11,6 @@
<file>Fields/Enum.qml</file>
<file>Fields/Text.qml</file>
<file>Fields/TextArea.qml</file>
<file>Filter.qml</file>
<file>Tags.qml</file>
<file>Sidebar.qml</file>
<file>Video.qml</file>