diff --git a/Event.qml b/Event.qml index 38d3eeb..7bad6d5 100644 --- a/Event.qml +++ b/Event.qml @@ -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 { } } - // Event‐specific inputs. + // Event‐specific 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 } } } diff --git a/Events.qml b/Events.qml index 8853f90..218884a 100644 --- a/Events.qml +++ b/Events.qml @@ -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 don’t 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 event’s 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() } } } diff --git a/Fields/Bool.qml b/Fields/Bool.qml index ccb0758..f6cd184 100644 --- a/Fields/Bool.qml +++ b/Fields/Bool.qml @@ -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 } diff --git a/Fields/Enum.qml b/Fields/Enum.qml index 30712b6..cb49b3b 100644 --- a/Fields/Enum.qml +++ b/Fields/Enum.qml @@ -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) } } } diff --git a/Fields/Text.qml b/Fields/Text.qml index 49d7ad2..b4e4dbf 100644 --- a/Fields/Text.qml +++ b/Fields/Text.qml @@ -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: { diff --git a/Fields/TextArea.qml b/Fields/TextArea.qml index 20cfeff..7be3564 100644 --- a/Fields/TextArea.qml +++ b/Fields/TextArea.qml @@ -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 diff --git a/Filter.qml b/Filter.qml deleted file mode 100644 index e1b5f93..0000000 --- a/Filter.qml +++ /dev/null @@ -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 - } -} diff --git a/Sidebar.qml b/Sidebar.qml index 2e61713..f66e5a6 100644 --- a/Sidebar.qml +++ b/Sidebar.qml @@ -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 don’t 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) - } } } } diff --git a/Tags.qml b/Tags.qml index 7d3f0ce..9e64cff 100644 --- a/Tags.qml +++ b/Tags.qml @@ -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 } } } diff --git a/event_list.cpp b/event_list.cpp new file mode 100644 index 0000000..2654bef --- /dev/null +++ b/event_list.cpp @@ -0,0 +1,141 @@ +#include "event_list.h" + +#include +#include + +Qt::ItemFlags EventList::flags(const QModelIndex&) const +{ + return Qt::ItemIsSelectable | Qt::ItemIsEditable | Qt::ItemIsEnabled | Qt::ItemNeverHasChildren; +} + +QHash EventList::roleNames() const +{ + static const QHash 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().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; +} diff --git a/event_list.h b/event_list.h new file mode 100644 index 0000000..1c50c59 --- /dev/null +++ b/event_list.h @@ -0,0 +1,46 @@ +#ifndef EVENT_LIST_H +#define EVENT_LIST_H + +#include +#include +#include +#include + +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 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 events; + + int find(long long time) const; +}; + +#endif diff --git a/fuzbal.pro b/fuzbal.pro index 56538ec..59be94b 100644 --- a/fuzbal.pro +++ b/fuzbal.pro @@ -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 diff --git a/main.cpp b/main.cpp index a388f27..4c933f6 100644 --- a/main.cpp +++ b/main.cpp @@ -14,7 +14,7 @@ #include #include -#include +#include "io.h" int main(int argc, char *argv[]) try { diff --git a/main.qrc b/main.qrc index 8a4a58d..52d2058 100644 --- a/main.qrc +++ b/main.qrc @@ -11,7 +11,6 @@ Fields/Enum.qml Fields/Text.qml Fields/TextArea.qml - Filter.qml Tags.qml Sidebar.qml Video.qml