/*** 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 "projectexplorer.h" #include #include #include #include #include #include #include #include #include "oakutil/define.h" #include "core.h" #include "dialog/footageproperties/footageproperties.h" #include "dialog/proxy/proxydialog.h" #include "dialog/sequence/sequence.h" #include "projectexplorerundo.h" #include "oakengine/footage.h" #include "oakengine/node.h" #include "oakengine/task.h" #include "oakengine/videoparams.h" #include "oakengine/project.h" #include "oakengine/undo.h" #include "widget/menu/menu.h" #include "widget/menu/menushared.h" #include "window/mainwindow/mainwindow.h" #include "window/mainwindow/mainwindowundo.h" #include "widget/timelinewidget/timelinewidget.h" namespace olive { namespace { QVector get_selected_proxy_footage(const QVector &items) { QVector footage; for (const oak::Node &node : items) { oak_video_params _vp; if (!node.is_footage() || oakengine_viewer_get_first_enabled_video_stream( node.handle(), &_vp) < 0 || !oakengine_video_params_is_valid(&_vp) || footage.contains(node)) { continue; } footage.append(node); } return footage; } } ProjectExplorer::ProjectExplorer(QWidget *parent) : QWidget(parent) , model_(this) { // Create layout QVBoxLayout *layout = new QVBoxLayout(this); layout->setSpacing(0); layout->setContentsMargins(0, 0, 0, 0); // Set up navigation bar nav_bar_ = new ProjectExplorerNavigation(this); connect(nav_bar_, &ProjectExplorerNavigation::size_changed, this, &ProjectExplorer::size_changed_slot); connect(nav_bar_, &ProjectExplorerNavigation::directory_up_clicked, this, &ProjectExplorer::dir_up_slot); layout->addWidget(nav_bar_); // Set up stacked widget stacked_widget_ = new QStackedWidget(this); layout->addWidget(stacked_widget_); // Set up sort filter proxy model sort_model_.setSourceModel(&model_); sort_model_.setFilterCaseSensitivity(Qt::CaseInsensitive); sort_model_.setSortRole(ProjectViewModel::k_inner_text_role); // Add tree view to stacked widget tree_view_ = new ProjectExplorerTreeView(stacked_widget_); tree_view_->setSortingEnabled(true); tree_view_->sortByColumn(0, Qt::AscendingOrder); tree_view_->setContextMenuPolicy(Qt::CustomContextMenu); add_view(tree_view_); // Add list view to stacked widget list_view_ = new ProjectExplorerListView(stacked_widget_); list_view_->setContextMenuPolicy(Qt::CustomContextMenu); add_view(list_view_); // Add icon view to stacked widget icon_view_ = new ProjectExplorerIconView(stacked_widget_); icon_view_->setContextMenuPolicy(Qt::CustomContextMenu); add_view(icon_view_); // Set default view to tree view set_view_type(ProjectToolbar::tree_view); // Set default icon size size_changed_slot(k_project_icon_size_default); connect(tree_view_, &ProjectExplorerTreeView::customContextMenuRequested, this, &ProjectExplorer::show_context_menu); connect(list_view_, &ProjectExplorerListView::customContextMenuRequested, this, &ProjectExplorer::show_context_menu); connect(icon_view_, &ProjectExplorerIconView::customContextMenuRequested, this, &ProjectExplorer::show_context_menu); update_nav_bar_text(); } const ProjectToolbar::ViewType &ProjectExplorer::view_type() const { return view_type_; } void ProjectExplorer::set_view_type(ProjectToolbar::ViewType type) { view_type_ = type; // Set widget based on view type switch (view_type_) { case ProjectToolbar::tree_view: stacked_widget_->setCurrentWidget(tree_view_); nav_bar_->setVisible(false); break; case ProjectToolbar::list_view: stacked_widget_->setCurrentWidget(list_view_); nav_bar_->setVisible(true); break; case ProjectToolbar::icon_view: stacked_widget_->setCurrentWidget(icon_view_); nav_bar_->setVisible(true); break; } } void ProjectExplorer::edit(OakEngineNode *item) { current_view()->edit( sort_model_.mapFromSource(model_.create_index_from_item(oak::Node(item)))); } void ProjectExplorer::add_view(QAbstractItemView *view) { view->setModel(&sort_model_); view->setEditTriggers(QAbstractItemView::SelectedClicked); connect(view, &QAbstractItemView::doubleClicked, this, &ProjectExplorer::item_double_clicked_slot); connect(view->selectionModel(), &QItemSelectionModel::selectionChanged, this, &ProjectExplorer::view_selection_changed); connect(view, SIGNAL(double_clicked_empty_area()), this, SLOT(view_empty_area_double_clicked_slot())); stacked_widget_->addWidget(view); } void ProjectExplorer::browse_to_folder(const QModelIndex &index) { // Set appropriate views to this index icon_view_->setRootIndex(index); list_view_->setRootIndex(index); // Set navbar text to folder's name update_nav_bar_text(); // Set directory up enabled button based on whether we're in root or not nav_bar_->set_dir_up_enabled(index.isValid()); } int ProjectExplorer::confirm_item_deletion(oak::Node item) { QMessageBox msgbox(this); msgbox.setWindowTitle(tr("Confirm Item Deletion")); msgbox.setIcon(QMessageBox::Warning); QStringList connected_nodes_names; for (int i = 0; i < item.output_connection_count(); i++) { oak::Node connected_node = item.output_connection_node(i); if (!connected_node.is_folder()) { connected_nodes_names.append( get_human_readable_node_name(connected_node)); } } msgbox.setText( tr("The item \"%1\" is currently connected to the following nodes:\n\n" "%2\n\n" "Are you sure you wish to delete this footage?") .arg(get_human_readable_node_name(item), connected_nodes_names.join('\n'))); // Set up buttons msgbox.addButton(QMessageBox::Yes); msgbox.addButton(QMessageBox::YesToAll); msgbox.addButton(QMessageBox::No); msgbox.addButton(QMessageBox::Cancel); // Run messagebox return msgbox.exec(); } bool ProjectExplorer::delete_items_internal(const QVector &selected, bool &check_if_item_is_in_use, void *command) { for (int i = 0; i < selected.size(); i++) { // Delete sequences first oak::Node node = selected.at(i); bool can_delete_item = true; if (check_if_item_is_in_use) { for (int j = 0; j < node.output_connection_count(); j++) { oak::Node oc_node = node.output_connection_node(j); if (!oc_node.is_folder()) { // This sequence outputs to SOMETHING, confirm the user if they want to delete this int r = confirm_item_deletion(node); switch (r) { case QMessageBox::No: can_delete_item = false; break; case QMessageBox::Cancel: return false; case QMessageBox::YesToAll: check_if_item_is_in_use = false; break; } } } } if (can_delete_item) { if (node.is_sequence() && Core::instance()->main_window()->is_sequence_open(node.handle())) { oakengine_undo_command_multi_add_child(command, make_close_sequence_command(node.handle())); } oak::Node parent_folder = node.folder(); if (parent_folder) { oakengine_undo_command_multi_add_child( command, oakengine_folder_remove_element_command( parent_folder.handle(), node.handle())); } void *remove_cmd = oakengine_undo_command_create_multi(); oakengine_undo_command_multi_add_child( remove_cmd, oakengine_node_remove_and_disconnect_command( node.handle())); for (int d = 0; d < node.exclusive_dependency_count(); d++) { oak::Node dep = node.exclusive_dependency_at(d); oakengine_undo_command_multi_add_child( remove_cmd, oakengine_node_remove_and_disconnect_command( dep.handle())); } oakengine_undo_command_multi_add_child(command, remove_cmd); } } return true; } QString ProjectExplorer::get_human_readable_node_name(const oak::Node &node) { if (node.get_label().isEmpty()) { return node.name(); } else { return tr("%1 (%2)").arg(node.get_label(), node.name()); } } void ProjectExplorer::update_nav_bar_text() { QString absolute; oak::Node f(static_cast( sort_model_.mapToSource(list_view_->rootIndex()).internalPointer())); while (f && f != project().root()) { absolute.prepend(QStringLiteral("%1 / ").arg(f.get_label())); f = f.folder(); } absolute.prepend(QStringLiteral("/ ")); nav_bar_->set_text(absolute); } QAbstractItemView *ProjectExplorer::current_view() const { return static_cast(stacked_widget_->currentWidget()); } void ProjectExplorer::view_empty_area_double_clicked_slot() { emit double_clicked_item(nullptr); } void ProjectExplorer::item_double_clicked_slot(const QModelIndex &index) { // Retrieve source item from index oak::Node i( static_cast(sort_model_.mapToSource(index).internalPointer())); // If the item is a folder, browse to it if (i.is_folder() && (view_type() == ProjectToolbar::list_view || view_type() == ProjectToolbar::icon_view)) { browse_to_folder(index); } // Emit a signal emit double_clicked_item(i.handle()); } void ProjectExplorer::size_changed_slot(int s) { icon_view_->setGridSize(QSize(s, s)); list_view_->setIconSize(QSize(s, s)); } void ProjectExplorer::dir_up_slot() { QModelIndex current_root = icon_view_->rootIndex(); if (current_root.isValid()) { QModelIndex parent = current_root.parent(); browse_to_folder(parent); } } void ProjectExplorer::rename_selected_item() { auto indexes = current_view()->selectionModel()->selectedRows(); if (!indexes.empty()) { current_view()->edit(indexes.first()); } } void ProjectExplorer::set_search_filter(const QString &s) { sort_model_.setFilterFixedString(s); } void ProjectExplorer::show_context_menu() { Menu menu; Menu new_menu; context_menu_items_ = selected_items(); if (context_menu_items_.isEmpty()) { // Items to show if no items are selected // "New" menu new_menu.setTitle(tr("&New")); MenuShared::instance()->add_items_for_new_menu(&new_menu); menu.addMenu(&new_menu); // "Import" action QAction *import_action = menu.addAction(tr("&Import...")); connect(import_action, &QAction::triggered, Core::instance(), &Core::dialog_import_show); } else { // Actions to add when only one item is selected if (context_menu_items_.size() == 1) { oak::Node context_menu_item = context_menu_items_.first(); if (context_menu_item.is_folder()) { QAction *open_in_new_tab = menu.addAction(tr("Open in New Tab")); connect(open_in_new_tab, &QAction::triggered, this, &ProjectExplorer::open_context_menu_item_in_new_tab); QAction *open_in_new_window = menu.addAction(tr("Open in New Window")); connect(open_in_new_window, &QAction::triggered, this, &ProjectExplorer::open_context_menu_item_in_new_window); } else if (context_menu_item.is_footage()) { QString reveal_text; #if defined(Q_OS_WINDOWS) reveal_text = tr("Reveal in Explorer"); #elif defined(Q_OS_MAC) reveal_text = tr("Reveal in Finder"); #else reveal_text = tr("Reveal in File Manager"); #endif QAction *reveal_action = menu.addAction(reveal_text); connect(reveal_action, &QAction::triggered, this, &ProjectExplorer::reveal_selected_footage); QAction *replace_action = menu.addAction(tr("Replace Footage")); connect(replace_action, &QAction::triggered, this, &ProjectExplorer::replace_selected_footage); } menu.addSeparator(); } bool all_items_are_footage = true; bool all_items_have_video_streams = true; bool all_items_are_footage_or_sequence = true; foreach (const oak::Node &i, context_menu_items_) { bool is_footage = i.is_footage(); bool is_sequence = i.is_sequence(); if (is_footage && !oakengine_viewer_has_enabled_streams( i.handle(), OAKENGINE_TRACK_TYPE_VIDEO)) { all_items_have_video_streams = false; } if (!is_footage) { all_items_are_footage = false; } if (!is_footage && !is_sequence) { all_items_are_footage_or_sequence = false; } } if (all_items_are_footage && all_items_have_video_streams) { const QVector proxy_footage = get_selected_proxy_footage(context_menu_items_); Menu *proxy_menu = new Menu(tr("Proxy"), &menu); menu.addMenu(proxy_menu); QAction *generate_proxy = proxy_menu->addAction(tr("Generate Proxy")); generate_proxy->setEnabled(!proxy_footage.isEmpty()); connect(generate_proxy, &QAction::triggered, this, &ProjectExplorer::generate_proxies_for_selected_footage); QAction *use_proxy = proxy_menu->addAction(tr("Use Proxy")); use_proxy->setCheckable(true); use_proxy->setEnabled(!proxy_footage.isEmpty()); use_proxy->setChecked( !proxy_footage.isEmpty() && std::all_of(proxy_footage.cbegin(), proxy_footage.cend(), [](const oak::Node &node) { oak::Footage f = node.as_footage(); return f && f.proxy_enabled(); })); connect(use_proxy, &QAction::triggered, this, &ProjectExplorer::set_selected_footage_proxy_enabled); QAction *reveal_proxy = proxy_menu->addAction(tr("Reveal Proxy")); reveal_proxy->setEnabled( std::any_of(proxy_footage.cbegin(), proxy_footage.cend(), [](const oak::Node &node) { oak::Footage f = node.as_footage(); return f && !f.proxy_path().isEmpty(); })); connect(reveal_proxy, &QAction::triggered, this, &ProjectExplorer::reveal_proxy_for_selected_footage); QAction *delete_proxy = proxy_menu->addAction(tr("Delete Proxy")); delete_proxy->setEnabled( std::any_of(proxy_footage.cbegin(), proxy_footage.cend(), [](const oak::Node &node) { oak::Footage f = node.as_footage(); return f && !f.proxy_path().isEmpty(); })); connect(delete_proxy, &QAction::triggered, this, &ProjectExplorer::delete_proxies_for_selected_footage); QAction *proxy_settings = proxy_menu->addAction(tr("Proxy Settings...")); connect(proxy_settings, &QAction::triggered, this, &ProjectExplorer::show_proxy_dialog_for_selected_footage); } Q_UNUSED(all_items_are_footage_or_sequence) if (context_menu_items_.size() == 1) { menu.addSeparator(); auto rename_action = menu.addAction(tr("Rename")); connect(rename_action, &QAction::triggered, this, &ProjectExplorer::rename_selected_item); } auto delete_action = menu.addAction(tr("Delete")); connect(delete_action, &QAction::triggered, this, &ProjectExplorer::delete_selected); if (context_menu_items_.size() == 1) { menu.addSeparator(); QAction *properties_action = menu.addAction(tr("P&roperties")); connect(properties_action, &QAction::triggered, this, &ProjectExplorer::show_item_properties_dialog); } } menu.exec(QCursor::pos()); } void ProjectExplorer::show_item_properties_dialog() { oak::Node sel = context_menu_items_.first(); // FIXME: Support for multiple items if (sel.is_footage()) { FootagePropertiesDialog fpd(this, sel.handle()); fpd.exec(); } else if (sel.is_folder()) { QVector handles; handles.reserve(context_menu_items_.size()); for (const oak::Node &n : context_menu_items_) { handles.append(n.handle()); } Core::instance()->label_nodes(handles); } else if (sel.is_sequence()) { SequenceDialog sd(sel.handle(), SequenceDialog::k_existing, this); sd.exec(); } } void ProjectExplorer::reveal_selected_footage() { oak::Footage footage = context_menu_items_.first().as_footage(); #if defined(Q_OS_WINDOWS) // Explorer QStringList args; args << "/select," << QDir::toNativeSeparators(footage.filename()); QProcess::startDetached("explorer", args); #elif defined(Q_OS_MAC) QStringList args; args << "-e"; args << "tell application \"Finder\""; args << "-e"; args << "activate"; args << "-e"; args << "select POSIX file \"" + footage.filename() + "\""; args << "-e"; args << "end tell"; QProcess::startDetached("osascript", args); #else QDesktopServices::openUrl(QUrl::fromLocalFile( QFileInfo(footage.filename()).dir().absolutePath())); #endif } void ProjectExplorer::replace_selected_footage() { oak::Node node = context_menu_items_.first(); oak::Footage footage = node.as_footage(); QString file = QFileDialog::getOpenFileName(this, tr("Replace Footage"), QString(), Core::footage_file_dialog_filter()); if (!file.isEmpty()) { if (!Core::is_footage_extension_allowed(file)) { QMessageBox::warning( this, tr("Unsupported media"), tr("This file type is not allowed by the current media type " "filter.")); return; } // Change the filename through the facade relink (reprobes the new // file and resets proxy/stream state); the label policy stays here. const int relink_rc = footage.relink(file); if (relink_rc != OAKENGINE_OK) { const QString err = oak::Footage::last_error(); QMessageBox::warning( this, tr("Cannot replace footage"), !err.isEmpty() ? err : tr("The file could not be used as media.")); return; } if (QFileInfo(footage.filename()).fileName() == node.get_label()) { // Footage label == filename, change label too node.set_label(QFileInfo(file).fileName()); } } } void ProjectExplorer::open_context_menu_item_in_new_tab() { Core::instance()->main_window()->open_folder( context_menu_items_.first().handle(), false); } void ProjectExplorer::open_context_menu_item_in_new_window() { Core::instance()->main_window()->open_folder( context_menu_items_.first().handle(), true); } void ProjectExplorer::generate_proxies_for_selected_footage() { if (!project()) { qWarning() << "GenerateProxiesForSelectedFootage: no project"; return; } const QVector footage = get_selected_proxy_footage(context_menu_items_); qDebug() << "GenerateProxiesForSelectedFootage: starting proxy generation for" << footage.size() << "footage item(s)"; for (const oak::Node &item : footage) { oak_video_params _vp; if (oakengine_viewer_get_first_enabled_video_stream( item.handle(), &_vp) < 0 || !oakengine_video_params_is_valid(&_vp)) { oak::Footage f = item.as_footage(); qWarning() << "GenerateProxiesForSelectedFootage: skipping item with no valid video stream" << (f ? f.filename() : QString()); continue; } // Queue one facade-backed task per footage item (same queueing // semantics as the old per-footage proxy tasks). OakEngineTask *proxy_task = oakengine_task_create_proxy( item.handle()); oakengine_task_manager_add(proxy_task); } } void ProjectExplorer::set_selected_footage_proxy_enabled(bool enabled) { const QVector footage = get_selected_proxy_footage(context_menu_items_); qDebug() << "ProjectExplorer::SetSelectedFootageProxyEnabled:" << enabled << "footage count=" << footage.size(); for (const oak::Node &item : footage) { oak::Footage f = item.as_footage(); if (!f || f.proxy_path().isEmpty()) { qDebug() << " skipping item with empty proxy path" << (f ? f.filename() : QString()); continue; } f.set_proxy_enabled(enabled); // The facade call toggles the flag; cache invalidation for the UI // stays here. f.invalidate(); } } void ProjectExplorer::reveal_proxy_for_selected_footage() { const QVector footage = get_selected_proxy_footage(context_menu_items_); for (const oak::Node &item : footage) { oak::Footage f = item.as_footage(); if (!f) { continue; } const QString path = f.proxy_path(); if (path.isEmpty()) { continue; } #if defined(Q_OS_WINDOWS) QStringList args; args << "/select," << QDir::toNativeSeparators(path); QProcess::startDetached(QStringLiteral("explorer"), args); #elif defined(Q_OS_MAC) QStringList args; args << "-e"; args << "tell application \"Finder\""; args << "-e"; args << "activate"; args << "-e"; args << "select POSIX file \"" + path + "\""; args << "-e"; args << "end tell"; QProcess::startDetached(QStringLiteral("osascript"), args); #else QDesktopServices::openUrl(QUrl::fromLocalFile( QFileInfo(path).dir().absolutePath())); #endif } } void ProjectExplorer::delete_proxies_for_selected_footage() { const QVector footage = get_selected_proxy_footage(context_menu_items_); for (const oak::Node &item : footage) { oak::Footage f = item.as_footage(); if (!f || f.proxy_path().isEmpty()) { continue; } // Facade delete: removes the file, clears the proxy state and // invalidates the footage. f.proxy_delete(); } } void ProjectExplorer::show_proxy_dialog_for_selected_footage() { QVector handles; const QVector footage = get_selected_proxy_footage(context_menu_items_); handles.reserve(footage.size()); for (const oak::Node &n : footage) { handles.append(n.handle()); } ProxyDialog d(this, handles); d.exec(); } void ProjectExplorer::view_selection_changed() { QItemSelectionModel *model = static_cast(sender()); QModelIndexList selection = model->selectedIndexes(); QVector nodes; foreach (const QModelIndex &index, selection) { auto handle = static_cast( sort_model_.mapToSource(index).internalPointer()); if (!nodes.contains(handle)) { nodes.append(handle); } } if (nodes.isEmpty()) { nodes.append(get_root().handle()); } emit selection_changed(nodes); } oak::Project ProjectExplorer::project() const { return model_.project(); } void ProjectExplorer::set_project(oak::Project p) { model_.set_project(p); } oak::Node ProjectExplorer::get_root() const { QModelIndex root_index = sort_model_.mapToSource(tree_view_->rootIndex()); if (!root_index.isValid()) { return project().root(); } return oak::Node(static_cast(root_index.internalPointer())); } void ProjectExplorer::set_root(oak::Node item) { QModelIndex index = sort_model_.mapFromSource(model_.create_index_from_item(item)); browse_to_folder(index); tree_view_->setRootIndex(index); } QVector ProjectExplorer::selected_items() const { // Determine which view is active and get its selected indexes QModelIndexList index_list = current_view()->selectionModel()->selectedRows(); // Convert indexes to item objects QVector selected_items; for (int i = 0; i < index_list.size(); i++) { QModelIndex index = sort_model_.mapToSource(index_list.at(i)); oak::Node item(static_cast(index.internalPointer())); selected_items.append(item); } return selected_items; } oak::Node ProjectExplorer::get_selected_folder() const { if (project() == nullptr) { return oak::Node(); } oak::Node folder; // Get the selected items from the panel QVector selected_nodes = selected_items(); // Heuristic for finding the selected folder: // // - If `folder` is nullptr, we set the first folder we find. Either the item itself if it's a folder, or the // item's parent. // - Otherwise, if all folders found are the same, we'll use that to import into. // - If more than one folder is found, we play it safe and import into the root folder for (int i = 0; i < selected_nodes.size(); i++) { oak::Node sel_item = selected_nodes.at(i); // If this item is not a folder, presumably it's parent is if (!sel_item.is_folder()) { sel_item = sel_item.folder(); } if (folder == nullptr) { // If the folder is nullptr, cache it as this folder folder = sel_item; } else if (folder != sel_item) { // If not, we've already cached a folder so we check if it's the same // If it isn't, we "play it safe" and use the root folder folder = oak::Node(); break; } } // If we didn't pick up a folder from the heuristic above for whatever reason, use root if (folder == nullptr) { folder = project().root(); } return folder; } ProjectViewModel *ProjectExplorer::model() { return &model_; } void ProjectExplorer::select_all() { current_view()->selectAll(); } void ProjectExplorer::deselect_all() { current_view()->selectionModel()->clearSelection(); } void ProjectExplorer::delete_selected() { QVector selected = selected_items(); if (selected.isEmpty()) { return; } void *command = oakengine_undo_command_create_multi(); bool check_if_item_is_in_use = true; if (delete_items_internal(selected, check_if_item_is_in_use, command)) { oakengine_undo_push( command, tr("Deleted %1 Item(s)").arg(selected.size()).toUtf8().constData()); } else { oakengine_undo_command_free(command); } } bool ProjectExplorer::select_item(oak::Node n, bool deselect_all_first) { if (deselect_all_first) { deselect_all(); } QModelIndex index = model_.create_index_from_item(n); if (index.isValid()) { index = sort_model_.mapFromSource(index); QModelIndex parent = index.parent(); if (view_type() == ProjectToolbar::tree_view) { // Expand all folders until this index is visible while (parent.isValid()) { tree_view_->expand(parent); parent = parent.parent(); } } else { browse_to_folder(parent); } current_view()->selectionModel()->select( index, QItemSelectionModel::Select | QItemSelectionModel::Rows); return true; } return false; } }