/*** Olive - Non-Linear Video Editor Copyright (C) 2022 Olive Team Modifications Copyright (C) 2025 mikesolar This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . ***/ #include "projectviewmodel.h" #include "ui/icons/icons.h" #include #include #include #include "oakutil/qtutils.h" #include "core.h" namespace olive { ProjectViewModel::ProjectViewModel(QObject *parent) : QAbstractItemModel(parent) , project_(nullptr) , bridge_(new EngineEventBridge(this)) { connect_bridge_signals(); } void ProjectViewModel::connect_bridge_signals() { connect(bridge_, &EngineEventBridge::folder_begin_insert_item, this, [this](OakEngineNode *folder, OakEngineNode *child, int index) { this->folder_begin_insert_item(folder, child, index); }); connect(bridge_, &EngineEventBridge::folder_end_insert_item, this, [this](OakEngineNode *) { this->folder_end_insert_item(); }); connect(bridge_, &EngineEventBridge::folder_begin_remove_item, this, [this](OakEngineNode *folder, OakEngineNode *child, int index) { this->folder_begin_remove_item(folder, child, index); }); connect(bridge_, &EngineEventBridge::folder_end_remove_item, this, [this](OakEngineNode *) { this->folder_end_remove_item(); }); connect(bridge_, &EngineEventBridge::node_label_changed, this, &ProjectViewModel::item_renamed); } oak::Project ProjectViewModel::project() const { return project_; } void ProjectViewModel::set_project(oak::Project p) { beginResetModel(); if (project_) { disconnect_item(project_.root()); // Recreate bridge to clear all folder subscriptions delete bridge_; bridge_ = new EngineEventBridge(this); connect_bridge_signals(); } project_ = p; if (project_) { connect_item(project_.root()); } endResetModel(); } QModelIndex ProjectViewModel::index(int row, int column, const QModelIndex &parent) const { // I'm actually not 100% sure what this does, but it seems logical and was in the earlier code if (!hasIndex(row, column, parent)) { return QModelIndex(); } // Get the parent object, we assume it's a folder since only folders can have children oak::Node item_parent = get_item_object_from_index(parent); // Return an index to this object return createIndex(row, column, item_parent.item_child(row).handle()); } QModelIndex ProjectViewModel::parent(const QModelIndex &child) const { // Get the Item object from the index oak::Node item = get_item_object_from_index(child); // Get Item's parent object oak::Node par = item.folder(); // If the parent is the root, return an empty index if (par == project_.root()) { return QModelIndex(); } // Otherwise return a true index to its parent int parent_index = index_of_child(par); // Make sure the index is valid (there's no reason it shouldn't be) Q_ASSERT(parent_index > -1); // Return an index to the parent return createIndex(parent_index, 0, par.handle()); } int ProjectViewModel::rowCount(const QModelIndex &parent) const { // If there's no project, there are obviously no items to show if (project_ == nullptr) { return 0; } // If the index is the root, return the root child count if (parent == QModelIndex()) { return project_.root().item_child_count(); } // Otherwise, the index must contain a valid pointer, so we just return its child count return get_item_object_from_index(parent).item_child_count(); } int ProjectViewModel::columnCount(const QModelIndex &parent) const { Q_UNUSED(parent) // Not strictly necessary, but a decent visual cue that there's no project currently active if (project_ == nullptr) { return 0; } return k_column_count; } QVariant ProjectViewModel::data(const QModelIndex &index, int role) const { oak::Node internal_item = get_item_object_from_index(index); ColumnType column_type = static_cast(index.column()); switch (role) { case Qt::DisplayRole: case k_inner_text_role: { // Standard text role switch (column_type) { case k_name: return internal_item.get_label(); case k_duration: return internal_item.data(1); case k_rate: return internal_item.data(4); case k_last_modified: case k_created_time: { const int data_role = (column_type == k_last_modified) ? 3 : 2; qint64 using_time = internal_item.data(data_role).toLongLong(); if (using_time == 0) { // 0 is the null value, return nothing break; } QVariant ret; if (role == k_inner_text_role) { // Use time value directly for correct sorting ret = using_time; } else { // Display role, format to a human readable string ret = QtUtils::get_formatted_date_time( QDateTime::fromSecsSinceEpoch(using_time)); } return ret; } case k_column_count: break; } } break; case Qt::EditRole: if (column_type == k_name) { return internal_item.get_label(); } break; case Qt::DecorationRole: // If this is the first column, return the Item's icon if (column_type == k_name) { return icon::from_name(internal_item.data(0).toString()); } break; case Qt::ToolTipRole: return internal_item.data(5); } return QVariant(); } QVariant ProjectViewModel::headerData(int section, Qt::Orientation orientation, int role) const { // Check if we need text data (DisplayRole) and orientation is horizontal // FIXME I'm not 100% sure what happens if the orientation is vertical/if that check is necessary if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { ColumnType column_type = static_cast(section); // Return the name based on the column's current type switch (column_type) { case k_name: return tr("Name"); case k_duration: return tr("Duration"); case k_rate: return tr("Rate"); case k_last_modified: return tr("Modified"); case k_created_time: return tr("Created"); case k_column_count: break; } } return QAbstractItemModel::headerData(section, orientation, role); } bool ProjectViewModel::hasChildren(const QModelIndex &parent) const { // If it's a folder, we always return TRUE in order to always show the "expand triangle" icon, // even when there are no "physical" children return get_item_object_from_index(parent).is_folder(); } bool ProjectViewModel::setData(const QModelIndex &index, const QVariant &value, int role) { // The name is editable if (index.isValid() && index.column() == k_name && role == Qt::EditRole) { oak::Node item = get_item_object_from_index(index); QString new_name = value.toString(); if (!new_name.isEmpty()) { void *nrc = oakengine_node_rename_command( item.handle(), new_name.toUtf8().constData()); oakengine_undo_push( nrc, tr("Renamed Item \"%1\" to \"%2\"").arg(item.get_label(), new_name).toUtf8().constData()); return true; } } return false; } bool ProjectViewModel::canFetchMore(const QModelIndex &parent) const { // Use the same hack that always returns true with folders so the expand triangle is always visible return hasChildren(parent); } Qt::ItemFlags ProjectViewModel::flags(const QModelIndex &index) const { if (!index.isValid()) { // Allow dropping files from external sources return Qt::ItemIsDropEnabled; } Qt::ItemFlags f = Qt::ItemIsDragEnabled | QAbstractItemModel::flags(index); if (get_item_object_from_index(index).is_folder()) { f |= Qt::ItemIsDropEnabled; } // If the column is the kName column, that means it's editable if (index.column() == k_name) { f |= Qt::ItemIsEditable; } return f; } QStringList ProjectViewModel::mimeTypes() const { // Allow data from this model and a file list from external sources return { QString::fromUtf8(oakengine_project_item_mime_type()), QStringLiteral("text/uri-list") }; } QMimeData *ProjectViewModel::mimeData(const QModelIndexList &indexes) const { // Compliance with Qt standard if (indexes.isEmpty()) { return nullptr; } // Encode mime data for the rows/items that were dragged QMimeData *data = new QMimeData(); // Use QDataStream to stream the item data into a byte array QByteArray encoded_data; QDataStream stream(&encoded_data, QIODevice::WriteOnly); // The indexes list includes indexes for each column which we don't use. To make sure each row only gets sent *once*, // we keep a list of dragged items QVector dragged_items; foreach (QModelIndex index, indexes) { if (index.isValid()) { // Check if we've dragged this item before if (!dragged_items.contains(index.internalPointer())) { // If not, add it to the stream (and also keep track of it in the vector) oak::Node item(static_cast(index.internalPointer())); // Serialize the enabled streams (type/index pairs) for viewer items const QVector> streams = item.is_viewer_output() ? item.enabled_streams() : QVector>(); stream << streams.size(); for (const QPair &s : streams) { stream << s.first << s.second; } stream << reinterpret_cast(item.handle()); dragged_items.append(item.handle()); } } } // Set byte array as the mime data and return the mime data data->setData(QString::fromUtf8(oakengine_project_item_mime_type()), encoded_data); return data; } bool ProjectViewModel::dropMimeData(const QMimeData *data, Qt::DropAction action, int row, int column, const QModelIndex &drop) { // Default recommended checks from https://doc.qt.io/qt-5/model-view-programming.html#using-drag-and-drop-with-item-views if (!canDropMimeData(data, action, row, column, drop)) { return false; } if (action == Qt::IgnoreAction) { return true; } // Probe mime data for its format QStringList mime_formats = data->formats(); if (mime_formats.contains(QString::fromUtf8(oakengine_project_item_mime_type()))) { // Data is drag/drop data from this model QByteArray model_data = data->data(QString::fromUtf8(oakengine_project_item_mime_type())); // Use QDataStream to deserialize the data QDataStream stream(&model_data, QIODevice::ReadOnly); // Get the Item object that the items were dropped on oak::Node drop_location = get_item_object_from_index(drop); // If this is not a folder, we cannot drop these items here if (!drop_location.is_folder()) { return false; } // Loop through all data, collecting the items to move QVector items_to_move; while (!stream.atEnd()) { // Written as qsizetype (8 bytes) by mimeData(); must match qint64 stream_count = 0; stream >> stream_count; for (qint64 si = 0; si < stream_count; si++) { int st = 0, sidx = 0; stream >> st >> sidx; } quintptr item_ptr; stream >> item_ptr; oak::Node item(reinterpret_cast(item_ptr)); // Check if Item is already the drop location or if its parent is the drop location, in which case this is a // no-op if (item != drop_location && item.folder() != drop_location && (!item.is_folder() || !item_is_parent_of_child(item, drop_location))) { items_to_move.append(item.handle()); } } if (!items_to_move.isEmpty()) { // ONE undoable command for the whole move (facade removes each // item from its old folder, then adds it to the drop location) oakengine_folder_move_children( items_to_move.constData(), items_to_move.size(), drop_location.handle(), tr("Move %1 Item(s)").arg(items_to_move.size()).toUtf8().constData()); } return true; } else if (mime_formats.contains(QStringLiteral("text/uri-list"))) { // We received a list of files QByteArray file_data = data->data(QStringLiteral("text/uri-list")); // Use text stream to parse (just an easy way of sifting through line breaks QTextStream stream(&file_data); // Convert QByteArray to QStringList (which Core takes for importing) QStringList urls; while (!stream.atEnd()) { QUrl url = stream.readLine(); if (!url.isEmpty()) { urls.append(url.toLocalFile()); } } // Get folder dropped onto oak::Node drop_item = get_item_object_from_index(drop); // If we didn't drop onto an item, find the nearest parent folder (should eventually terminate at root either way) if (!drop_item.is_folder()) { drop_item = drop_item.folder(); if (!drop_item) { // Failed to find folder to place this in return false; } } // Trigger an import Core::instance()->import_files(urls, drop_item.handle()); return true; } return false; } int ProjectViewModel::index_of_child(oak::Node item) const { // Find parent's index within its own parent oak::Node parent = item.folder(); if (parent) { return parent.index_of_child(item); } return -1; } oak::Node ProjectViewModel::get_item_object_from_index(const QModelIndex &index) const { if (index.isValid()) { return oak::Node(static_cast(index.internalPointer())); } return project_ ? project_.root() : oak::Node(); } bool ProjectViewModel::item_is_parent_of_child(oak::Node parent, oak::Node child) const { // Loop through parent hierarchy checking if `parent` is one of its parents do { child = child.folder(); if (parent == child) { return true; } } while (child != nullptr); return false; } void ProjectViewModel::connect_item(oak::Node n) { label_changed_subs_[n] = bridge_->subscribe( n.handle(), OAKENGINE_EVENT_NODE_LABEL_CHANGED); if (n.is_folder()) { bridge_->subscribe(n.handle(), OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM); bridge_->subscribe(n.handle(), OAKENGINE_EVENT_FOLDER_END_INSERT_ITEM); bridge_->subscribe(n.handle(), OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM); bridge_->subscribe(n.handle(), OAKENGINE_EVENT_FOLDER_END_REMOVE_ITEM); const int count = n.item_child_count(); for (int i = 0; i < count; i++) { connect_item(n.item_child(i)); } } } void ProjectViewModel::disconnect_item(oak::Node n) { int64_t sub = label_changed_subs_.take(n); if (sub > 0) { bridge_->unsubscribe(sub); } if (n.is_folder()) { // Bridge subscriptions are cleaned up by recreating the bridge in set_project const int count = n.item_child_count(); for (int i = 0; i < count; i++) { disconnect_item(n.item_child(i)); } } } void ProjectViewModel::folder_begin_insert_item(oak::Node folder, oak::Node n, int insert_index) { connect_item(n); QModelIndex index; if (folder != project_.root()) { index = create_index_from_item(folder); } beginInsertRows(index, insert_index, insert_index); } void ProjectViewModel::folder_end_insert_item() { endInsertRows(); } void ProjectViewModel::folder_begin_remove_item(oak::Node folder, oak::Node n, int child_index) { disconnect_item(n); QModelIndex index; if (folder != project_.root()) { index = create_index_from_item(folder); } beginRemoveRows(index, child_index, child_index); } void ProjectViewModel::folder_end_remove_item() { endRemoveRows(); } void ProjectViewModel::item_renamed(OakEngineNode *source) { QModelIndex index = create_index_from_item(oak::Node(source)); emit dataChanged(index, index, { Qt::DisplayRole, Qt::EditRole }); } QModelIndex ProjectViewModel::create_index_from_item(oak::Node item, int column) { return createIndex(index_of_child(item), column, item.handle()); } }