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:
parent
e9b70c585c
commit
cb76fedcbc
14 changed files with 375 additions and 342 deletions
76
Event.qml
76
Event.qml
|
@ -7,61 +7,77 @@ import QtQuick.Layouts 1.6
|
||||||
import 'util.js' as Util
|
import 'util.js' as Util
|
||||||
|
|
||||||
// This is the delegate for event list items.
|
// This is the delegate for event list items.
|
||||||
Pane {
|
ItemDelegate {
|
||||||
id: control
|
required property var model
|
||||||
|
required property int index
|
||||||
|
required property int time
|
||||||
|
|
||||||
property int time
|
property alias fields: inputs.model // field definitions
|
||||||
property alias tag: tag.text
|
|
||||||
property alias fields: inputs.model
|
|
||||||
property bool editing: false
|
property bool editing: false
|
||||||
|
|
||||||
signal remove
|
|
||||||
|
|
||||||
clip: true
|
clip: true
|
||||||
height: visible ? stuff.height + 2*padding : 0 // TODO fix filtering and remove this
|
|
||||||
padding: 2
|
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() {
|
function reset() {
|
||||||
for (var i = 0; i < fields.count; i++) {
|
for (var i = 0; i < fields.length; i++) {
|
||||||
const child = inputs.itemAt(i)
|
const child = inputs.itemAt(i)
|
||||||
if (child && child.item)
|
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() {
|
function store() {
|
||||||
for (var i = 0; i < fields.count; i++)
|
var values = {}
|
||||||
fields.setProperty(i, 'value', inputs.itemAt(i).item.value)
|
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)
|
Keys.forwardTo: Array.from({ length: inputs.count }, (_, i) => inputs.itemAt(i).item)
|
||||||
|
|
||||||
Behavior on height { NumberAnimation { duration: 50 } }
|
contentItem: ColumnLayout {
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
id: stuff
|
|
||||||
anchors { left: parent.left; right: parent.right; margins: 5 }
|
anchors { left: parent.left; right: parent.right; margins: 5 }
|
||||||
|
|
||||||
|
// Event time, tag and summary.
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Label {
|
Label {
|
||||||
text: new Date(time).toISOString().substr(12, 9)
|
text: new Date(model.time).toISOString().substr(12, 9)
|
||||||
font.pixelSize: 10
|
font.pixelSize: 10
|
||||||
Layout.alignment: Qt.AlignBaseline
|
Layout.alignment: Qt.AlignBaseline
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
id: tag
|
text: model.tag
|
||||||
font.weight: Font.DemiBold
|
font.weight: Font.DemiBold
|
||||||
Layout.alignment: Qt.AlignBaseline
|
Layout.alignment: Qt.AlignBaseline
|
||||||
}
|
}
|
||||||
Label {
|
Label {
|
||||||
text: {
|
text: {
|
||||||
var str = ''
|
var str = ''
|
||||||
for (var i = 0; i < fields.count; i++) {
|
for (var i = 0; i < inputs.count; i++) {
|
||||||
const field = fields.get(i)
|
const field = inputs.model[i]
|
||||||
if (field.value && field.type !== 'TextArea')
|
const value = inputs.itemAt(i).item.value
|
||||||
str += (field.type === 'Bool' ? field.name : field.value) + ' '
|
if (value && field.type !== 'TextArea')
|
||||||
|
str += (field.type === 'Bool' ? field.name : value) + ' '
|
||||||
}
|
}
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
@ -72,10 +88,8 @@ Pane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event‐specific inputs.
|
// Event‐specific input fields.
|
||||||
GridLayout {
|
GridLayout {
|
||||||
id: fieldset
|
|
||||||
|
|
||||||
flow: GridLayout.TopToBottom
|
flow: GridLayout.TopToBottom
|
||||||
rows: inputs.count
|
rows: inputs.count
|
||||||
|
|
||||||
|
@ -86,7 +100,7 @@ Pane {
|
||||||
Repeater {
|
Repeater {
|
||||||
model: inputs.model
|
model: inputs.model
|
||||||
delegate: Label {
|
delegate: Label {
|
||||||
text: Util.addShortcut(model.name, model.key)
|
text: Util.addShortcut(modelData.name, modelData.key)
|
||||||
Layout.alignment: Qt.AlignRight
|
Layout.alignment: Qt.AlignRight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -95,12 +109,12 @@ Pane {
|
||||||
Repeater {
|
Repeater {
|
||||||
id: inputs
|
id: inputs
|
||||||
delegate: Loader {
|
delegate: Loader {
|
||||||
source: 'qrc:/Fields/' + model.type + '.qml'
|
source: 'qrc:/Fields/' + modelData.type + '.qml'
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Binding {
|
Binding {
|
||||||
target: item; property: 'definition'
|
target: item; property: 'model'
|
||||||
value: model
|
value: modelData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
135
Events.qml
135
Events.qml
|
@ -3,139 +3,35 @@
|
||||||
import QtQuick 2.12
|
import QtQuick 2.12
|
||||||
import QtQuick.Controls 2.13
|
import QtQuick.Controls 2.13
|
||||||
import QtQuick.Layouts 1.6
|
import QtQuick.Layouts 1.6
|
||||||
import QtQml.Models 2.1
|
|
||||||
|
|
||||||
import 'util.js' as Util
|
|
||||||
|
|
||||||
ListView {
|
ListView {
|
||||||
id: control
|
id: control
|
||||||
|
|
||||||
|
required property var tags // tag definitions
|
||||||
property bool editing: false
|
property bool editing: false
|
||||||
property var tags: []
|
|
||||||
|
|
||||||
signal changed
|
|
||||||
|
|
||||||
clip: true
|
clip: true
|
||||||
focus: true
|
focus: true
|
||||||
keyNavigationEnabled: true
|
keyNavigationEnabled: true
|
||||||
highlightMoveDuration: 0
|
highlightMoveDuration: 0
|
||||||
highlightResizeDuration: 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
|
onCurrentIndexChanged: editing = false
|
||||||
|
|
||||||
Keys.onPressed: {
|
ScrollBar.vertical: ScrollBar { anchors.right: parent.right }
|
||||||
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 don’t lose focus when editing
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event.accepted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
model: ListModel {
|
|
||||||
id: list
|
|
||||||
dynamicRoles: true
|
|
||||||
}
|
|
||||||
|
|
||||||
delegate: Event {
|
delegate: Event {
|
||||||
id: item
|
// If field definitions are missing for this event’s tag, use
|
||||||
|
// Text for all field types unless where the value is bool.
|
||||||
time: model.time
|
fields: tags[model.tag] ? tags[model.tag].fields :
|
||||||
tag: model.tag
|
Object.entries(model.values).map(value => ({
|
||||||
fields: model.fields
|
'name': value[0],
|
||||||
|
'type': typeof(value[1]) === 'boolean' ? 'Bool' : 'Text',
|
||||||
|
}))
|
||||||
|
|
||||||
width: control.width
|
width: control.width
|
||||||
editing: control.editing && ListView.isCurrentItem
|
editing: control.editing && ListView.isCurrentItem
|
||||||
|
highlighted: 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
|
|
||||||
}
|
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
enabled: ListView.currentIndex === index
|
enabled: ListView.currentIndex === index
|
||||||
|
@ -143,13 +39,10 @@ ListView {
|
||||||
control.positionViewAtIndex(index, ListView.Contain)
|
control.positionViewAtIndex(index, ListView.Contain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onEditingChanged: {
|
|
||||||
reset()
|
onClicked: {
|
||||||
if (editing)
|
control.currentIndex = index
|
||||||
forceActiveFocus()
|
control.forceActiveFocus()
|
||||||
}
|
|
||||||
onRemove: {
|
|
||||||
list.remove(ObjectModel.index)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,14 +4,13 @@ import QtQuick 2.12
|
||||||
import QtQuick.Controls 2.13
|
import QtQuick.Controls 2.13
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
id: control
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
|
||||||
property var definition
|
property var model
|
||||||
property alias value: input.checked
|
property alias value: input.checked
|
||||||
|
|
||||||
Keys.onPressed: {
|
Keys.onPressed: {
|
||||||
if (event.text === definition.key) {
|
if (event.text === model.key) {
|
||||||
value = !value
|
value = !value
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,13 +8,13 @@ import '../util.js' as Util
|
||||||
Column {
|
Column {
|
||||||
id: control
|
id: control
|
||||||
|
|
||||||
property var definition
|
property var model
|
||||||
property int index: -1
|
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) {
|
function set(val) {
|
||||||
for (var i = 0; i < definition.values.count; i++) {
|
for (var i = 0; i < model.values.length; i++) {
|
||||||
if (definition.values.get(i).name === val) {
|
if (model.values[i].name === val) {
|
||||||
index = i
|
index = i
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -23,8 +23,8 @@ Column {
|
||||||
}
|
}
|
||||||
|
|
||||||
Keys.onPressed: {
|
Keys.onPressed: {
|
||||||
for (var i = 0; i < definition.values.count; i++) {
|
for (var i = 0; i < model.values.length; i++) {
|
||||||
if (definition.values.get(i).key === event.text) {
|
if (model.values[i].key === event.text) {
|
||||||
index = (index === i ? -1 : i)
|
index = (index === i ? -1 : i)
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
break
|
break
|
||||||
|
@ -39,7 +39,7 @@ Column {
|
||||||
ButtonGroup { id: buttons }
|
ButtonGroup { id: buttons }
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: definition.values
|
model: control.model.values
|
||||||
delegate: Button {
|
delegate: Button {
|
||||||
ButtonGroup.group: buttons
|
ButtonGroup.group: buttons
|
||||||
checkable: true
|
checkable: true
|
||||||
|
@ -52,7 +52,7 @@ Column {
|
||||||
rightPadding: leftPadding
|
rightPadding: leftPadding
|
||||||
|
|
||||||
onClicked: control.index = (control.index === index ? -1 : index)
|
onClicked: control.index = (control.index === index ? -1 : index)
|
||||||
text: Util.addShortcut(name, key)
|
text: Util.addShortcut(modelData.name, modelData.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,11 +6,11 @@ import QtQuick.Controls 2.13
|
||||||
Label {
|
Label {
|
||||||
id: control
|
id: control
|
||||||
|
|
||||||
property var definition
|
property var model
|
||||||
property alias value: control.text
|
property alias value: control.text
|
||||||
|
|
||||||
Keys.onPressed: {
|
Keys.onPressed: {
|
||||||
if (event.text === definition.key) {
|
if (event.text === model.key) {
|
||||||
popup.open()
|
popup.open()
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
}
|
}
|
||||||
|
@ -23,8 +23,8 @@ Label {
|
||||||
Popup {
|
Popup {
|
||||||
id: popup
|
id: popup
|
||||||
|
|
||||||
width: control.width
|
width: parent.width
|
||||||
height: control.height
|
height: parent.height
|
||||||
padding: 0
|
padding: 0
|
||||||
|
|
||||||
onOpened: {
|
onOpened: {
|
||||||
|
|
|
@ -6,11 +6,11 @@ import QtQuick.Controls 2.13
|
||||||
Label {
|
Label {
|
||||||
id: control
|
id: control
|
||||||
|
|
||||||
property var definition
|
property var model
|
||||||
property alias value: control.text
|
property alias value: control.text
|
||||||
|
|
||||||
Keys.onPressed: {
|
Keys.onPressed: {
|
||||||
if (event.text === definition.key) {
|
if (event.text === model.key) {
|
||||||
popup.open()
|
popup.open()
|
||||||
event.accepted = true
|
event.accepted = true
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ Label {
|
||||||
Popup {
|
Popup {
|
||||||
id: popup
|
id: popup
|
||||||
|
|
||||||
width: control.width
|
width: parent.width
|
||||||
height: input.height
|
height: input.height
|
||||||
padding: 0
|
padding: 0
|
||||||
|
|
||||||
|
|
34
Filter.qml
34
Filter.qml
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
193
Sidebar.qml
193
Sidebar.qml
|
@ -5,48 +5,30 @@ import QtQuick.Controls 2.13
|
||||||
import QtQuick.Layouts 1.6
|
import QtQuick.Layouts 1.6
|
||||||
import Qt.labs.platform 1.1
|
import Qt.labs.platform 1.1
|
||||||
|
|
||||||
|
import fuzbal 1
|
||||||
|
|
||||||
Page {
|
Page {
|
||||||
id: control
|
id: control
|
||||||
|
|
||||||
property bool modified: false
|
property bool modified: false
|
||||||
property Video video
|
property Video video
|
||||||
|
|
||||||
function clear() {
|
EventList {
|
||||||
description.clear()
|
id: eventList
|
||||||
events.clear()
|
onDataChanged: modified = true
|
||||||
}
|
onRowsInserted: modified = true
|
||||||
|
onRowsRemoved: modified = true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FileDialog {
|
FileDialog {
|
||||||
id: videoDialog
|
id: videoDialog
|
||||||
title: qsTr('Open video')
|
title: qsTr('Open video')
|
||||||
onAccepted: {
|
onAccepted: {
|
||||||
clear()
|
|
||||||
video.source = currentFile
|
video.source = currentFile
|
||||||
const events = io.read(video.source+'.events')
|
const json = JSON.parse(io.read(currentFile+'.events') || '{}')
|
||||||
if (events)
|
eventList.load(json)
|
||||||
load(JSON.parse(events))
|
description.text = json['description'] || ''
|
||||||
|
modified = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,7 +36,7 @@ Page {
|
||||||
id: tagsDialog
|
id: tagsDialog
|
||||||
title: qsTr('Load tags')
|
title: qsTr('Load tags')
|
||||||
nameFilters: [qsTr('JSON files (*.json)'), qsTr('All files (*)')]
|
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]
|
Keys.forwardTo: [tags, video]
|
||||||
|
@ -78,7 +60,14 @@ Page {
|
||||||
}
|
}
|
||||||
ToolButton {
|
ToolButton {
|
||||||
action: Action {
|
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
|
shortcut: StandardKey.Save
|
||||||
icon.name: 'document-save'
|
icon.name: 'document-save'
|
||||||
enabled: video.loaded && control.modified
|
enabled: video.loaded && control.modified
|
||||||
|
@ -144,100 +133,92 @@ Page {
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
focus: true
|
focus: true
|
||||||
tags: tags.model
|
model: eventList
|
||||||
|
tags: eventList.tags
|
||||||
|
|
||||||
onEditingChanged: video.pause(editing)
|
onEditingChanged: video.pause(editing)
|
||||||
onChanged: modified = true
|
onCurrentItemChanged: {
|
||||||
|
if (currentItem)
|
||||||
|
video.seek(currentItem.time)
|
||||||
|
}
|
||||||
|
|
||||||
MouseArea {
|
Keys.onPressed: {
|
||||||
anchors.fill: parent
|
switch (event.key) {
|
||||||
enabled: !parent.editing
|
case Qt.Key_Home:
|
||||||
onPressed: {
|
currentIndex = 0
|
||||||
const index = events.indexAt(mouse.x, mouse.y)
|
break
|
||||||
if (index !== -1) {
|
case Qt.Key_End:
|
||||||
events.currentIndex = index
|
currentIndex = count-1
|
||||||
video.seek(events.itemAtIndex(index).time)
|
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 don’t lose focus when editing
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
event.accepted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Page {
|
// Tag list.
|
||||||
|
Frame {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: false
|
Layout.fillHeight: false
|
||||||
|
padding: 5
|
||||||
|
|
||||||
StackLayout {
|
ColumnLayout {
|
||||||
currentIndex: bar.currentIndex
|
|
||||||
implicitHeight: children[currentIndex].implicitHeight
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
Frame {
|
RowLayout {
|
||||||
padding: 5
|
Label {
|
||||||
enabled: visible
|
text: qsTr('Tags')
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
ColumnLayout {
|
}
|
||||||
width: parent.width
|
ToolButton {
|
||||||
spacing: 0
|
icon.name: 'document-open'
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
RowLayout {
|
onClicked: tagsDialog.open()
|
||||||
Label {
|
focusPolicy:Qt.NoFocus
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Frame {
|
Tags {
|
||||||
padding: 5
|
id: tags
|
||||||
enabled: visible
|
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
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
44
Tags.qml
44
Tags.qml
|
@ -2,44 +2,34 @@
|
||||||
|
|
||||||
import QtQuick 2.12
|
import QtQuick 2.12
|
||||||
import QtQuick.Controls 2.13
|
import QtQuick.Controls 2.13
|
||||||
import QtQuick.Layouts 1.6
|
|
||||||
|
|
||||||
import 'util.js' as Util
|
import 'util.js' as Util
|
||||||
|
|
||||||
// Tag list.
|
// Tag list.
|
||||||
Page {
|
Flow {
|
||||||
id: control
|
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.enabled: enabled
|
||||||
Keys.onPressed: {
|
Keys.forwardTo: Array.from({ length: buttons.count }, (_, i) => buttons.itemAt(i))
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
spacing: 5
|
||||||
width: parent.width
|
|
||||||
|
|
||||||
Flow {
|
Repeater {
|
||||||
spacing: 5
|
id: buttons
|
||||||
Layout.fillWidth: true
|
delegate: Button {
|
||||||
|
text: Util.addShortcut(modelData.tag, modelData.key)
|
||||||
Repeater {
|
focusPolicy: Qt.NoFocus
|
||||||
id: tags
|
implicitWidth: implicitContentWidth + 2*padding
|
||||||
delegate: Button {
|
onClicked: control.clicked(modelData.tag)
|
||||||
text: Util.addShortcut(modelData.tag, modelData.key)
|
Keys.onPressed: {
|
||||||
onClicked: control.clicked(modelData.tag, modelData.fields)
|
if (event.text === modelData.key) {
|
||||||
focusPolicy: Qt.NoFocus
|
clicked()
|
||||||
implicitWidth: implicitContentWidth + 2*padding
|
event.accepted = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
141
event_list.cpp
Normal file
141
event_list.cpp
Normal 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
46
event_list.h
Normal 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
|
10
fuzbal.pro
10
fuzbal.pro
|
@ -1,11 +1,15 @@
|
||||||
# SPDX-License-Identifier: Unlicense
|
# SPDX-License-Identifier: Unlicense
|
||||||
|
|
||||||
QT += multimedia qml quick quickcontrols2 svg widgets
|
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)\\\"
|
DEFINES += GIT_VERSION=\\\"$$system(git -C "$$_PRO_FILE_PWD_" describe --always --tags)\\\"
|
||||||
|
|
||||||
SOURCES += main.cpp
|
QML_IMPORT_NAME = fuzbal
|
||||||
HEADERS += io.h
|
QML_IMPORT_MAJOR_VERSION = 1
|
||||||
|
|
||||||
|
SOURCES += event_list.cpp main.cpp
|
||||||
|
HEADERS += event_list.h io.h
|
||||||
|
|
||||||
RESOURCES += main.qrc icons.qrc
|
RESOURCES += main.qrc icons.qrc
|
||||||
TRANSLATIONS += translations/fuzbal_sl.ts
|
TRANSLATIONS += translations/fuzbal_sl.ts
|
||||||
|
|
||||||
|
|
2
main.cpp
2
main.cpp
|
@ -14,7 +14,7 @@
|
||||||
#include <QTranslator>
|
#include <QTranslator>
|
||||||
#include <QtDebug>
|
#include <QtDebug>
|
||||||
|
|
||||||
#include <io.h>
|
#include "io.h"
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
try {
|
try {
|
||||||
|
|
1
main.qrc
1
main.qrc
|
@ -11,7 +11,6 @@
|
||||||
<file>Fields/Enum.qml</file>
|
<file>Fields/Enum.qml</file>
|
||||||
<file>Fields/Text.qml</file>
|
<file>Fields/Text.qml</file>
|
||||||
<file>Fields/TextArea.qml</file>
|
<file>Fields/TextArea.qml</file>
|
||||||
<file>Filter.qml</file>
|
|
||||||
<file>Tags.qml</file>
|
<file>Tags.qml</file>
|
||||||
<file>Sidebar.qml</file>
|
<file>Sidebar.qml</file>
|
||||||
<file>Video.qml</file>
|
<file>Video.qml</file>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue