From 755f1575eae23b96eeebfa48f83852d29ce374f2 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Mon, 20 Jul 2026 12:13:49 -0500 Subject: [PATCH] Add searchable submenu style in event editor --- qtfred/source_groups.cmake | 2 + qtfred/src/mission/EditorViewport.cpp | 7 + qtfred/src/mission/EditorViewport.h | 8 + .../dialogs/PreferencesDialogModel.cpp | 5 + .../mission/dialogs/PreferencesDialogModel.h | 4 + qtfred/src/ui/dialogs/PreferencesDialog.cpp | 5 + qtfred/src/ui/dialogs/PreferencesDialog.h | 1 + qtfred/src/ui/widgets/sexp_data_menu.cpp | 197 ++++++++++++++++++ qtfred/src/ui/widgets/sexp_data_menu.h | 30 +++ qtfred/src/ui/widgets/sexp_tree_view.cpp | 32 +-- qtfred/ui/PreferencesDialog.ui | 29 +++ 11 files changed, 304 insertions(+), 16 deletions(-) create mode 100644 qtfred/src/ui/widgets/sexp_data_menu.cpp create mode 100644 qtfred/src/ui/widgets/sexp_data_menu.h diff --git a/qtfred/source_groups.cmake b/qtfred/source_groups.cmake index 2858c90ab40..ec825910f5e 100644 --- a/qtfred/source_groups.cmake +++ b/qtfred/source_groups.cmake @@ -327,6 +327,8 @@ add_file_folder("Source/UI/Widgets" src/ui/widgets/PersonaColorComboBox.h src/ui/widgets/renderwidget.cpp src/ui/widgets/renderwidget.h + src/ui/widgets/sexp_data_menu.cpp + src/ui/widgets/sexp_data_menu.h src/ui/widgets/sexp_tree_view.cpp src/ui/widgets/sexp_tree_view.h src/ui/widgets/ShipFlagCheckbox.h diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp index cee5ef70045..4a93b7bd17d 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -145,6 +145,12 @@ void EditorViewport::loadSettings() { Show_sexp_help_wing_editor = settings.value("show_sexp_help_wing_editor", Show_sexp_help_wing_editor).toBool(); // Handles its own group, since main.cpp reads it before the viewport exists. Theme_mode = readThemeModeSetting(); + { + const int rawStyle = settings.value("sexp_data_menu_style", static_cast(Sexp_data_menu_style)).toInt(); + if (rawStyle >= 0 && rawStyle <= static_cast(SexpDataMenuStyle::Searchable)) { + Sexp_data_menu_style = static_cast(rawStyle); + } + } view.Universal_heading = settings.value("view_universal_heading", view.Universal_heading).toBool(); view.Show_stars = settings.value("view_show_stars", view.Show_stars).toBool(); @@ -192,6 +198,7 @@ void EditorViewport::saveSettings() const { settings.setValue("show_sexp_help_ship_editor", Show_sexp_help_ship_editor); settings.setValue("show_sexp_help_wing_editor", Show_sexp_help_wing_editor); writeThemeModeSetting(Theme_mode); + settings.setValue("sexp_data_menu_style", static_cast(Sexp_data_menu_style)); settings.setValue("view_universal_heading", view.Universal_heading); settings.setValue("view_show_stars", view.Show_stars); diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 91dca1b071a..aadfae7d747 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -29,6 +29,12 @@ enum class OtherKind { JumpNode, }; +enum class SexpDataMenuStyle { + Auto = 0, + Columns = 1, + Searchable = 2, +}; + struct ViewSettings { bool Universal_heading = false; bool Show_stars = true; @@ -232,6 +238,8 @@ class EditorViewport { ThemeMode Theme_mode = ThemeMode::System; + SexpDataMenuStyle Sexp_data_menu_style = SexpDataMenuStyle::Auto; + void saveSettings() const; Editor* editor = nullptr; diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp index e0ccc08e1fb..707be3baa79 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp @@ -23,6 +23,7 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport* , _showSexpHelpShipEditor(viewport->Show_sexp_help_ship_editor) , _showSexpHelpWingEditor(viewport->Show_sexp_help_wing_editor) , _themeMode(viewport->Theme_mode) + , _sexpDataMenuStyle(viewport->Sexp_data_menu_style) , _toolbarIconSize(viewport->toolbar_icon_size) , _outlineLod(viewport->view.Outline_lod) , _invertOrbitX(viewport->camera.getInvertOrbitX()) @@ -64,6 +65,7 @@ bool PreferencesDialogModel::apply() { _viewport->Show_sexp_help_ship_editor = _showSexpHelpShipEditor; _viewport->Show_sexp_help_wing_editor = _showSexpHelpWingEditor; _viewport->Theme_mode = _themeMode; + _viewport->Sexp_data_menu_style = _sexpDataMenuStyle; _viewport->toolbar_icon_size = _toolbarIconSize; _viewport->view.Outline_lod = _outlineLod; _viewport->camera.setInvertOrbitX(_invertOrbitX); @@ -169,6 +171,9 @@ void PreferencesDialogModel::setShowSexpHelpWingEditor(bool value) { modify(_sho ThemeMode PreferencesDialogModel::getThemeMode() const { return _themeMode; } void PreferencesDialogModel::setThemeMode(ThemeMode value) { modify(_themeMode, value); } +SexpDataMenuStyle PreferencesDialogModel::getSexpDataMenuStyle() const { return _sexpDataMenuStyle; } +void PreferencesDialogModel::setSexpDataMenuStyle(SexpDataMenuStyle value) { modify(_sexpDataMenuStyle, value); } + int PreferencesDialogModel::getToolbarIconSize() const { return _toolbarIconSize; } void PreferencesDialogModel::setToolbarIconSize(int size) { modify(_toolbarIconSize, size); } diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.h b/qtfred/src/mission/dialogs/PreferencesDialogModel.h index 58b85e872b9..8687e125e60 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.h +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.h @@ -58,6 +58,9 @@ class PreferencesDialogModel : public AbstractDialogModel { ThemeMode getThemeMode() const; void setThemeMode(ThemeMode value); + SexpDataMenuStyle getSexpDataMenuStyle() const; + void setSexpDataMenuStyle(SexpDataMenuStyle value); + int getToolbarIconSize() const; void setToolbarIconSize(int size); @@ -102,6 +105,7 @@ class PreferencesDialogModel : public AbstractDialogModel { bool _showSexpHelpShipEditor; bool _showSexpHelpWingEditor; ThemeMode _themeMode; + SexpDataMenuStyle _sexpDataMenuStyle; int _toolbarIconSize; int _outlineLod; diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp index a539a151651..110e926ad7f 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp +++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp @@ -120,6 +120,7 @@ void PreferencesDialog::updateUi() { ui->checkPotentialIssues->setChecked(_model->getCheckPotentialIssues()); ui->applyAutoCorrections->setChecked(_model->getApplyAutoCorrections()); ui->themeCombo->setCurrentIndex(themeModeToIndex(_model->getThemeMode())); + ui->sexpDataMenuStyleCombo->setCurrentIndex(static_cast(_model->getSexpDataMenuStyle())); const int iconSize = _model->getToolbarIconSize(); ui->toolbarIconSizeCombo->setCurrentIndex(iconSize <= 16 ? 0 : iconSize >= 32 ? 2 : 1); @@ -201,6 +202,10 @@ void PreferencesDialog::on_themeCombo_currentIndexChanged(int index) { _model->setThemeMode(themeModeFromIndex(index)); } +void PreferencesDialog::on_sexpDataMenuStyleCombo_currentIndexChanged(int index) { + _model->setSexpDataMenuStyle(static_cast(index)); +} + void PreferencesDialog::on_showSexpHelpMissionEvents_toggled(bool checked) { _model->setShowSexpHelpMissionEvents(checked); } diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.h b/qtfred/src/ui/dialogs/PreferencesDialog.h index 5187f35937b..0a88e68f4df 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.h +++ b/qtfred/src/ui/dialogs/PreferencesDialog.h @@ -34,6 +34,7 @@ private slots: void on_toolbarIconSizeCombo_currentIndexChanged(int index); void on_outlineLodCombo_currentIndexChanged(int index); void on_themeCombo_currentIndexChanged(int index); + void on_sexpDataMenuStyleCombo_currentIndexChanged(int index); void on_showSexpHelpMissionEvents_toggled(bool checked); void on_showSexpHelpMissionGoals_toggled(bool checked); void on_showSexpHelpMissionCutscenes_toggled(bool checked); diff --git a/qtfred/src/ui/widgets/sexp_data_menu.cpp b/qtfred/src/ui/widgets/sexp_data_menu.cpp new file mode 100644 index 00000000000..83e5d28f5a3 --- /dev/null +++ b/qtfred/src/ui/widgets/sexp_data_menu.cpp @@ -0,0 +1,197 @@ +#include "sexp_data_menu.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fso::fred { +namespace { + +constexpr int kDataIdxRole = Qt::UserRole + 1; + +void dismissAndActivate(int dataIdx, const std::function& onActivate) { + while (auto* p = QApplication::activePopupWidget()) { + p->close(); + } + // Defer the actual edit so it runs after the menu close cascade has finished + // processing — avoids reentrancy issues if the edit triggers more UI changes. + QTimer::singleShot(0, [onActivate, dataIdx]() { onActivate(dataIdx); }); +} + +class SearchableMenuWidget : public QWidget { +public: + SearchableMenuWidget(const std::vector& items, + std::function onActivate) + : QWidget(nullptr) + , _onActivate(std::move(onActivate)) + { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(4, 4, 4, 4); + layout->setSpacing(4); + + _filter = new QLineEdit(this); + _filter->setPlaceholderText(tr("Type to filter...")); + _filter->setClearButtonEnabled(true); + layout->addWidget(_filter); + + _model = new QStandardItemModel(0, 1, this); + for (const auto& it : items) { + auto* row = new QStandardItem(it.text); + row->setData(it.dataIdx, kDataIdxRole); + row->setEditable(false); + _model->appendRow(row); + } + + _proxy = new QSortFilterProxyModel(this); + _proxy->setFilterCaseSensitivity(Qt::CaseInsensitive); + _proxy->setSourceModel(_model); + + _list = new QListView(this); + _list->setModel(_proxy); + _list->setEditTriggers(QAbstractItemView::NoEditTriggers); + _list->setUniformItemSizes(true); + _list->setSelectionMode(QAbstractItemView::SingleSelection); + _list->setFrameShape(QFrame::NoFrame); + + const QFontMetrics fm(_list->font()); + const int rowHeight = fm.height() + 6; + int maxTextWidth = 0; + for (const auto& it : items) { + maxTextWidth = std::max(maxTextWidth, fm.horizontalAdvance(it.text)); + } + _list->setFixedWidth(std::clamp(maxTextWidth + 32, 220, 480)); + _list->setMinimumHeight(rowHeight * 13); + layout->addWidget(_list); + + QObject::connect(_filter, &QLineEdit::textChanged, this, [this](const QString& text) { + _proxy->setFilterFixedString(text); + if (_proxy->rowCount() > 0) { + _list->setCurrentIndex(_proxy->index(0, 0)); + } + }); + QObject::connect(_filter, &QLineEdit::returnPressed, this, [this]() { activateCurrent(); }); + QObject::connect(_list, &QListView::activated, this, [this](const QModelIndex&) { activateCurrent(); }); + QObject::connect(_list, &QListView::clicked, this, [this](const QModelIndex&) { activateCurrent(); }); + + if (_proxy->rowCount() > 0) { + _list->setCurrentIndex(_proxy->index(0, 0)); + } + } + + QLineEdit* filterEdit() { return _filter; } + +protected: + void keyPressEvent(QKeyEvent* event) override { + switch (event->key()) { + case Qt::Key_Down: + case Qt::Key_Up: + case Qt::Key_PageDown: + case Qt::Key_PageUp: + QApplication::sendEvent(_list, event); + return; + case Qt::Key_Escape: + while (auto* p = QApplication::activePopupWidget()) { + p->close(); + } + return; + default: + break; + } + QWidget::keyPressEvent(event); + } + +private: + void activateCurrent() { + const QModelIndex idx = _list->currentIndex(); + if (!idx.isValid()) { + return; + } + dismissAndActivate(idx.data(kDataIdxRole).toInt(), _onActivate); + } + + std::function _onActivate; + QLineEdit* _filter = nullptr; + QListView* _list = nullptr; + QStandardItemModel* _model = nullptr; + QSortFilterProxyModel* _proxy = nullptr; +}; + +void appendActions(QMenu* menu, + const std::vector& items, + const std::function& onActivate) +{ + for (const auto& item : items) { + const int idx = item.dataIdx; + menu->addAction(item.text, menu, [onActivate, idx]() { onActivate(idx); }); + } +} + +// Resolves Auto to a concrete style: the native column menu while the list fits +// comfortably, then the searchable popup once it would grow past half the screen. +// The menu is not shown yet, so height is estimated from the item count. +SexpDataMenuStyle resolveAutoStyle(const QMenu* menu, int itemCount) { + // Extra rows for the Number / String / separator entries already in the menu. + constexpr int fixedRows = 3; + const QFontMetrics fm(menu->font()); + const int rowHeight = fm.height() + 6; + const int estimatedHeight = (itemCount + fixedRows) * rowHeight; + + const QScreen* screen = menu->screen(); + if (screen == nullptr) { + screen = QGuiApplication::primaryScreen(); + } + if (screen == nullptr) { + return SexpDataMenuStyle::Columns; + } + + const int available = screen->availableGeometry().height(); + return estimatedHeight > available / 2 ? SexpDataMenuStyle::Searchable + : SexpDataMenuStyle::Columns; +} + +} // namespace + +void populateSexpDataSubmenu(QMenu* menu, + const std::vector& items, + SexpDataMenuStyle style, + std::function onActivate) +{ + if (!menu || items.empty()) { + return; + } + + if (style == SexpDataMenuStyle::Auto) { + style = resolveAutoStyle(menu, static_cast(items.size())); + } + + switch (style) { + case SexpDataMenuStyle::Auto: + case SexpDataMenuStyle::Columns: + // Native menu: the platform tiles a too-tall list into columns. + appendActions(menu, items, onActivate); + break; + case SexpDataMenuStyle::Searchable: { + auto* widget = new SearchableMenuWidget(items, std::move(onActivate)); + auto* action = new QWidgetAction(menu); + action->setDefaultWidget(widget); + menu->addAction(action); + // Focus the filter once the menu is fully shown. + QObject::connect(menu, &QMenu::aboutToShow, widget, [widget]() { + widget->filterEdit()->setFocus(); + }); + break; + } + } +} + +} // namespace fso::fred diff --git a/qtfred/src/ui/widgets/sexp_data_menu.h b/qtfred/src/ui/widgets/sexp_data_menu.h new file mode 100644 index 00000000000..ffc2ddf59dd --- /dev/null +++ b/qtfred/src/ui/widgets/sexp_data_menu.h @@ -0,0 +1,30 @@ +#pragma once + +#include "mission/EditorViewport.h" + +#include +#include +#include + +class QMenu; + +namespace fso::fred { + +struct SexpDataMenuItem { + QString text; + int dataIdx; +}; + +// Populates the OPF data list portion of an "Add Data" / "Replace Data" submenu +// using the chosen style. Pre-existing items in `menu` (Number, String, +// separator) are left intact; the data list is appended after them. +// +// onActivate is invoked with the chosen item's dataIdx when the user selects +// an entry. Callers are responsible for resolving that index against the +// original sexp_list_item linked list. +void populateSexpDataSubmenu(QMenu* menu, + const std::vector& items, + SexpDataMenuStyle style, + std::function onActivate); + +} // namespace fso::fred diff --git a/qtfred/src/ui/widgets/sexp_tree_view.cpp b/qtfred/src/ui/widgets/sexp_tree_view.cpp index ee88baa1c63..aaa234fb473 100644 --- a/qtfred/src/ui/widgets/sexp_tree_view.cpp +++ b/qtfred/src/ui/widgets/sexp_tree_view.cpp @@ -1,12 +1,14 @@ #include "sexp_tree_view.h" #include "mission/util.h" #include "mission/Editor.h" +#include "mission/EditorViewport.h" #include "mission/object.h" #include #include #include #include +#include #include #include @@ -1325,32 +1327,30 @@ std::unique_ptr sexp_tree_view::buildContextMenu(QTreeWidgetItem* h) { replace_number_act->setEnabled(state.can_replace_number); replace_string_act->setEnabled(state.can_replace_string); - // Build add data menu items - if (state.add_data_list) { - sexp_list_item* ptr = state.add_data_list; + const SexpDataMenuStyle dataMenuStyle = _viewport ? _viewport->Sexp_data_menu_style : SexpDataMenuStyle::Columns; + + auto collectDataItems = [](sexp_list_item* head) { + std::vector out; + sexp_list_item* ptr = head; int data_idx = 0; while (ptr) { if (ptr->op < 0) { - add_data_menu->addAction(QString::fromStdString(ptr->text), - this, [this, data_idx]() { addReplaceTypedDataHandler(data_idx, false); }); + out.push_back({QString::fromStdString(ptr->text), data_idx}); } data_idx++; ptr = ptr->next; } + return out; + }; + + if (state.add_data_list) { + populateSexpDataSubmenu(add_data_menu, collectDataItems(state.add_data_list), dataMenuStyle, + [this](int data_idx) { addReplaceTypedDataHandler(data_idx, false); }); } - // Build replace data menu items if (state.replace_data_list) { - sexp_list_item* ptr = state.replace_data_list; - int data_idx = 0; - while (ptr) { - if (ptr->op < 0) { - replace_data_menu->addAction(QString::fromStdString(ptr->text), - this, [this, data_idx]() { addReplaceTypedDataHandler(data_idx, true); }); - } - data_idx++; - ptr = ptr->next; - } + populateSexpDataSubmenu(replace_data_menu, collectDataItems(state.replace_data_list), dataMenuStyle, + [this](int data_idx) { addReplaceTypedDataHandler(data_idx, true); }); } // Clipboard and copy operations diff --git a/qtfred/ui/PreferencesDialog.ui b/qtfred/ui/PreferencesDialog.ui index dd27b89f86d..7fbea1dc657 100644 --- a/qtfred/ui/PreferencesDialog.ui +++ b/qtfred/ui/PreferencesDialog.ui @@ -127,6 +127,35 @@ + + + + SEXP data menu style: + + + + + + + Controls how the "Add Data" / "Replace Data" submenus present long lists like ships and wings. Auto uses Columns for short lists and Searchable once a list would fill more than half the screen. + + + + Auto + + + + + Columns + + + + + Searchable + + + +