diff --git a/framework/interactive/internal/interactive.cpp b/framework/interactive/internal/interactive.cpp index 9d0fe8f701..00a0e9fa44 100644 --- a/framework/interactive/internal/interactive.cpp +++ b/framework/interactive/internal/interactive.cpp @@ -44,6 +44,10 @@ #include "muse_framework_config.h" #include "defer.h" +#ifdef Q_OS_MAC +#include "platform/macos/macosinteractivehelper.h" +#endif + #include "log.h" using namespace muse; @@ -355,15 +359,25 @@ async::Promise Interactive::selectOpeningFile(const std::string& tit { #ifndef Q_OS_LINUX return async::make_promise([title, dir, filter](auto resolve, auto reject) { +#ifdef Q_OS_MAC + auto scope = std::make_shared(); +#endif QFileDialog* dlg = new QFileDialog(nullptr, QString::fromStdString(title), dir.toQString(), filterToString(filter)); dlg->setFileMode(QFileDialog::ExistingFile); - QObject::connect(dlg, &QFileDialog::finished, [dlg, resolve, reject](int result) { + QObject::connect(dlg, &QFileDialog::finished, [dlg, resolve, reject +#ifdef Q_OS_MAC + , scope +#endif + ](int result) mutable { DEFER { //! Must be called AFTER resolve/reject, as they may process posted events dlg->deleteLater(); }; +#ifdef Q_OS_MAC + scope.reset(); +#endif QStringList files = dlg->selectedFiles(); @@ -404,6 +418,9 @@ io::path_t Interactive::selectOpeningFileSync(const std::string& title, const io const int options) { #ifndef Q_OS_LINUX +#ifdef Q_OS_MAC + MacOSInteractiveHelper::NativeDialogScope scope; +#endif const QFileDialog::Options qoptions = QFileDialog::Options::fromInt(options); QString result = QFileDialog::getOpenFileName(nullptr, QString::fromStdString(title), dir.toQString(), filterToString( filter), nullptr, qoptions); @@ -424,6 +441,9 @@ io::paths_t Interactive::selectOpeningFilesSync(const std::string& title, const const int options) { #ifndef Q_OS_LINUX +#ifdef Q_OS_MAC + MacOSInteractiveHelper::NativeDialogScope scope; +#endif const QFileDialog::Options qoptions = QFileDialog::Options::fromInt(options); const QStringList result = QFileDialog::getOpenFileNames(nullptr, QString::fromStdString(title), dir.toQString(), filterToString( filter), nullptr, qoptions); @@ -445,6 +465,9 @@ io::path_t Interactive::selectSavingFileSync(const std::string& title, const io: bool confirmOverwrite) { #ifndef Q_OS_LINUX +#ifdef Q_OS_MAC + MacOSInteractiveHelper::NativeDialogScope scope; +#endif QFileDialog::Options options; options.setFlag(QFileDialog::DontConfirmOverwrite, !confirmOverwrite); QString result = QFileDialog::getSaveFileName(nullptr, QString::fromStdString(title), dir.toQString(), filterToString( @@ -467,10 +490,12 @@ io::path_t Interactive::selectSavingFileSync(const std::string& title, const io: io::path_t Interactive::selectDirectory(const std::string& title, const io::path_t& dir) { #ifndef Q_OS_LINUX +#ifdef Q_OS_MAC + MacOSInteractiveHelper::NativeDialogScope scope; +#endif QString result = QFileDialog::getExistingDirectory(nullptr, QString::fromStdString(title), dir.toQString()); return result; #else - UriQuery q("muse://interactive/selectdir"); q.set("title", title); q.set("folder", QUrl::fromLocalFile(dir.toQString()).toLocalFile().toStdString()); diff --git a/framework/interactive/internal/platform/macos/macosinteractivehelper.h b/framework/interactive/internal/platform/macos/macosinteractivehelper.h index 88e19b1352..a4282a9eb8 100644 --- a/framework/interactive/internal/platform/macos/macosinteractivehelper.h +++ b/framework/interactive/internal/platform/macos/macosinteractivehelper.h @@ -22,6 +22,8 @@ #pragma once +#include + #include "io/path.h" #include "types/ret.h" @@ -33,10 +35,34 @@ class UriQuery; class MacOSInteractiveHelper { public: + enum class EditAction { + Undo, + Redo, + Cut, + Copy, + Paste, + SelectAll + }; + + static void setEditMenuIndex(int menuIndex); + static void setEditMenuStructure(const std::map& structure); + static bool revealInFinder(const io::path_t& filePath); static Ret isAppExists(const std::string& appIdentifier); static Ret canOpenApp(const UriQuery& uri); static async::Promise openApp(const UriQuery& uri); + + class NativeDialogScope + { + public: + NativeDialogScope(); + ~NativeDialogScope(); + + NativeDialogScope(const NativeDialogScope&) = delete; + NativeDialogScope& operator=(const NativeDialogScope&) = delete; + NativeDialogScope(NativeDialogScope&&) = delete; + NativeDialogScope& operator=(NativeDialogScope&&) = delete; + }; }; } diff --git a/framework/interactive/internal/platform/macos/macosinteractivehelper.mm b/framework/interactive/internal/platform/macos/macosinteractivehelper.mm index ccf348d28b..55bca13a3b 100644 --- a/framework/interactive/internal/platform/macos/macosinteractivehelper.mm +++ b/framework/interactive/internal/platform/macos/macosinteractivehelper.mm @@ -25,6 +25,7 @@ #include #include +#include #include "types/uri.h" @@ -96,3 +97,329 @@ return Promise::Result::unchecked(); }); } + +#import + +struct SavedMenuItemState { + NSMenuItem* item; + id target; + SEL action; + NSString* keyEquivalent; + NSEventModifierFlags modifierMask; + BOOL isEnabled; + NSImage* image; + NSInteger preferredImageVisibility; + BOOL hasActionImage; + NSImage* actionImage; + bool wasCreated; + SEL scopeAction; +}; + +struct ActionSpec { + MacOSInteractiveHelper::EditAction editAction; + SEL action; + NSString* key; + NSEventModifierFlags mod; + NSString* fallbackTitle; +}; + +static const std::vector s_actionSpecs = { + { MacOSInteractiveHelper::EditAction::Undo, @selector(undo:), @"z", NSEventModifierFlagCommand, + @"Undo" }, + { MacOSInteractiveHelper::EditAction::Redo, @selector(redo:), @"Z", (NSEventModifierFlagCommand | NSEventModifierFlagShift), + @"Redo" }, + { MacOSInteractiveHelper::EditAction::Cut, @selector(cut:), @"x", NSEventModifierFlagCommand, + @"Cut" }, + { MacOSInteractiveHelper::EditAction::Copy, @selector(copy:), @"c", NSEventModifierFlagCommand, + @"Copy" }, + { MacOSInteractiveHelper::EditAction::Paste, @selector(paste:), @"v", NSEventModifierFlagCommand, + @"Paste" }, + { MacOSInteractiveHelper::EditAction::SelectAll, @selector(selectAll:), @"a", NSEventModifierFlagCommand, + @"Select All" }, +}; + +static std::vector s_savedStates; +static NSMenu* s_editMenu = nil; +static std::vector s_menuObservers; +static BOOL s_savedAutoenables = YES; +static int s_nativeDialogCount = 0; +static bool s_isTransforming = false; +static int s_customEditMenuIndex = -1; +static std::map s_customEditMenuStructure; + +void MacOSInteractiveHelper::setEditMenuIndex(int menuIndex) +{ + s_customEditMenuIndex = menuIndex; +} + +void MacOSInteractiveHelper::setEditMenuStructure(const std::map& structure) +{ + s_customEditMenuStructure = structure; +} + +static NSMenuItem* matchByStructure(const ActionSpec& spec) +{ + if (!s_editMenu) { + return nil; + } + + int index = -1; + auto it = s_customEditMenuStructure.find(spec.editAction); + if (it != s_customEditMenuStructure.end()) { + index = it->second; + } else if (s_customEditMenuStructure.empty()) { + switch (spec.editAction) { + case MacOSInteractiveHelper::EditAction::Undo: index = 0; + break; + case MacOSInteractiveHelper::EditAction::Redo: index = 1; + break; + case MacOSInteractiveHelper::EditAction::Cut: index = 2; + break; + case MacOSInteractiveHelper::EditAction::Copy: index = 3; + break; + case MacOSInteractiveHelper::EditAction::Paste: index = 4; + break; + case MacOSInteractiveHelper::EditAction::SelectAll: index = 5; + break; + } + } + + if (index >= 0) { + NSArray* items = [s_editMenu itemArray]; + if (index < (int)[items count]) { + NSMenuItem* candidate = items[index]; + for (const auto& s : s_savedStates) { + if (s.wasCreated && s.item == candidate) { + return nil; + } + } + return candidate; + } + } + return nil; +} + +static void ensureFallbackItemsExist() +{ + if (!s_editMenu) { + return; + } + + for (const auto& spec : s_actionSpecs) { + bool isPresentInMenu = false; + for (const auto& s : s_savedStates) { + if (s.scopeAction == spec.action && [s.item menu] == s_editMenu) { + isPresentInMenu = true; + break; + } + } + if (!isPresentInMenu) { + NSMenuItem* newItem = [[NSMenuItem alloc] initWithTitle:spec.fallbackTitle + action:spec.action + keyEquivalent:spec.key]; + [newItem setKeyEquivalentModifierMask:spec.mod]; + [newItem setTarget:nil]; + [newItem setEnabled:YES]; + [newItem setHidden:YES]; + [newItem setImage:nil]; + if ([newItem respondsToSelector:@selector(setPreferredImageVisibility:)]) { + ((void (*)(id, SEL, NSInteger)) objc_msgSend)(newItem, @selector(setPreferredImageVisibility:), 1); + } + if ([newItem respondsToSelector:@selector(_setHasActionImage:)]) { + ((void (*)(id, SEL, BOOL)) objc_msgSend)(newItem, @selector(_setHasActionImage:), NO); + } + if ([newItem respondsToSelector:@selector(_setActionImage:)]) { + ((void (*)(id, SEL, id)) objc_msgSend)(newItem, @selector(_setActionImage:), nil); + } + if ([newItem respondsToSelector:@selector(_setActionImageName:)]) { + ((void (*)(id, SEL, id)) objc_msgSend)(newItem, @selector(_setActionImageName:), nil); + } + if ([newItem respondsToSelector:@selector(setAllowsKeyEquivalentWhenHidden:)]) { + [newItem setAllowsKeyEquivalentWhenHidden:YES]; + } + [s_editMenu addItem:newItem]; + s_savedStates.push_back({ newItem, nil, nil, nil, 0, YES, nil, 0, NO, nil, true, spec.action }); + } + } +} + +static void applyScopeTransformations() +{ + if (s_isTransforming || s_nativeDialogCount <= 0) { + return; + } + + NSMenu* mainMenu = [NSApp mainMenu]; + NSMenu* liveEditMenu = nil; + if (mainMenu && s_customEditMenuIndex >= 0 && s_customEditMenuIndex < (int)[[mainMenu itemArray] count]) { + liveEditMenu = [[mainMenu itemArray][s_customEditMenuIndex] submenu]; + } + if (liveEditMenu != s_editMenu) { + if (s_editMenu) { + [s_editMenu setAutoenablesItems:s_savedAutoenables]; + [s_editMenu release]; + } + s_editMenu = [liveEditMenu retain]; + if (s_editMenu) { + s_savedAutoenables = [s_editMenu autoenablesItems]; + } + } + + if (!s_editMenu) { + return; + } + + s_isTransforming = true; + + [s_editMenu setAutoenablesItems:NO]; + + auto isAlreadySaved = [](NSMenuItem* candidate) { + for (const auto& s : s_savedStates) { + if (s.item == candidate) { + return true; + } + } + return false; + }; + + for (const auto& spec : s_actionSpecs) { + NSMenuItem* matchedItem = matchByStructure(spec); + if (matchedItem) { + if (!isAlreadySaved(matchedItem)) { + NSImage* img = [matchedItem image]; + NSInteger visibility = 0; + if ([matchedItem respondsToSelector:@selector(preferredImageVisibility)]) { + visibility = ((NSInteger (*)(id, SEL)) objc_msgSend)(matchedItem, @selector(preferredImageVisibility)); + } + BOOL hasActionImg = NO; + if ([matchedItem respondsToSelector:@selector(_hasActionImage)]) { + hasActionImg = ((BOOL (*)(id, SEL)) objc_msgSend)(matchedItem, @selector(_hasActionImage)); + } + NSImage* actImg = nil; + if ([matchedItem respondsToSelector:@selector(_actionImage)]) { + actImg = ((NSImage * (*)(id, SEL)) objc_msgSend)(matchedItem, @selector(_actionImage)); + } + s_savedStates.push_back({ [matchedItem retain], [matchedItem target], [matchedItem action], + [[matchedItem keyEquivalent] copy], [matchedItem keyEquivalentModifierMask], + [matchedItem isEnabled], [img retain], visibility, hasActionImg, + [actImg retain], false, spec.action }); + } + [matchedItem setTarget:nil]; + [matchedItem setAction:spec.action]; + [matchedItem setKeyEquivalent:spec.key]; + [matchedItem setKeyEquivalentModifierMask:spec.mod]; + [matchedItem setEnabled:YES]; + [matchedItem setImage:nil]; + if ([matchedItem respondsToSelector:@selector(setPreferredImageVisibility:)]) { + ((void (*)(id, SEL, NSInteger)) objc_msgSend)(matchedItem, @selector(setPreferredImageVisibility:), 1); + } + if ([matchedItem respondsToSelector:@selector(_setHasActionImage:)]) { + ((void (*)(id, SEL, BOOL)) objc_msgSend)(matchedItem, @selector(_setHasActionImage:), NO); + } + if ([matchedItem respondsToSelector:@selector(_setActionImage:)]) { + ((void (*)(id, SEL, id)) objc_msgSend)(matchedItem, @selector(_setActionImage:), nil); + } + if ([matchedItem respondsToSelector:@selector(_setActionImageName:)]) { + ((void (*)(id, SEL, id)) objc_msgSend)(matchedItem, @selector(_setActionImageName:), nil); + } + } + } + + ensureFallbackItemsExist(); + + s_isTransforming = false; +} + +MacOSInteractiveHelper::NativeDialogScope::NativeDialogScope() +{ + NSCAssert([NSThread isMainThread], @"NativeDialogScope must run on main thread"); + + s_nativeDialogCount++; + if (s_nativeDialogCount > 1) { + return; + } + + s_savedStates.clear(); + s_editMenu = nil; + + // Initial transformation of current menu items and creation of any missing fallback items + applyScopeTransformations(); + + // Register notification observers to safely protect against Qt/QML menu updates during modal dialog. + if (s_menuObservers.empty()) { + id beginObs = [[NSNotificationCenter defaultCenter] addObserverForName:NSMenuDidBeginTrackingNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* _Nonnull /*note*/) { + dispatch_async(dispatch_get_main_queue(), ^{ + applyScopeTransformations(); + }); + }]; + s_menuObservers.push_back(beginObs); + + id endObs = [[NSNotificationCenter defaultCenter] addObserverForName:NSMenuDidEndTrackingNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* _Nonnull /*note*/) { + applyScopeTransformations(); + }]; + s_menuObservers.push_back(endObs); + } +} + +MacOSInteractiveHelper::NativeDialogScope::~NativeDialogScope() +{ + NSCAssert([NSThread isMainThread], @"~NativeDialogScope must run on main thread"); + + s_nativeDialogCount--; + if (s_nativeDialogCount <= 0) { + s_nativeDialogCount = 0; + + for (id obs : s_menuObservers) { + [[NSNotificationCenter defaultCenter] removeObserver:obs]; + } + s_menuObservers.clear(); + + for (const auto& saved : s_savedStates) { + if (saved.wasCreated) { + if ([saved.item menu]) { + [[saved.item menu] removeItem:saved.item]; + } + [saved.item release]; + } else { + [saved.item setTarget:saved.target]; + [saved.item setAction:saved.action]; + [saved.item setKeyEquivalent:saved.keyEquivalent ? saved.keyEquivalent : @""]; + [saved.item setKeyEquivalentModifierMask:saved.modifierMask]; + [saved.item setEnabled:saved.isEnabled]; + [saved.item setImage:saved.image]; + if ([saved.item respondsToSelector:@selector(setPreferredImageVisibility:)]) { + ((void (*)(id, SEL, NSInteger)) objc_msgSend)(saved.item, @selector(setPreferredImageVisibility:), + saved.preferredImageVisibility); + } + if ([saved.item respondsToSelector:@selector(_setHasActionImage:)]) { + ((void (*)(id, SEL, BOOL)) objc_msgSend)(saved.item, @selector(_setHasActionImage:), + saved.hasActionImage); + } + if ([saved.item respondsToSelector:@selector(_setActionImage:)]) { + ((void (*)(id, SEL, id)) objc_msgSend)(saved.item, @selector(_setActionImage:), + saved.actionImage); + } + if (saved.actionImage) { + [saved.actionImage release]; + } + if (saved.image) { + [saved.image release]; + } + [saved.keyEquivalent release]; + [saved.item release]; + } + } + s_savedStates.clear(); + if (s_editMenu) { + [s_editMenu setAutoenablesItems:s_savedAutoenables]; + [s_editMenu release]; + s_editMenu = nil; + } + } +} diff --git a/framework/interactive/tests/CMakeLists.txt b/framework/interactive/tests/CMakeLists.txt index ac3ff16bb2..ebbf1b814f 100644 --- a/framework/interactive/tests/CMakeLists.txt +++ b/framework/interactive/tests/CMakeLists.txt @@ -24,4 +24,12 @@ set(MODULE_TEST_SRC ${CMAKE_CURRENT_LIST_DIR}/mocks/interactivemock.h ) +if (OS_IS_MAC) + list(APPEND MODULE_TEST_SRC + ${CMAKE_CURRENT_LIST_DIR}/macosinteractivehelper_tests.mm + ) +endif() + +set(MODULE_TEST_LINK muse_interactive) + include(SetupGTest) diff --git a/framework/interactive/tests/macosinteractivehelper_tests.mm b/framework/interactive/tests/macosinteractivehelper_tests.mm new file mode 100644 index 0000000000..ea233cd67e --- /dev/null +++ b/framework/interactive/tests/macosinteractivehelper_tests.mm @@ -0,0 +1,700 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * 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 + +#import +#import +#include "interactive/internal/platform/macos/macosinteractivehelper.h" + +@interface MockMenuTarget : NSObject +- (void)triggerAction:(id)sender; +- (void)fileSaveAction:(id)sender; +@end + +@implementation MockMenuTarget +- (void)triggerAction:(id)sender {} +- (void)fileSaveAction:(id)sender {} +@end + +class MacOSInteractiveHelperTest : public ::testing::Test +{ +protected: + void SetUp() override + { + m_pool = [[NSAutoreleasePool alloc] init]; + NSApplication* app = [NSApplication sharedApplication]; + if (!app) { + GTEST_SKIP() << "No macOS GUI / NSApplication session available"; + } + + m_mockTarget = [[MockMenuTarget alloc] init]; + + m_mainMenu = [[NSMenu alloc] initWithTitle:@"MainMenu"]; + [NSApp setMainMenu:m_mainMenu]; + if (![NSApp mainMenu]) { + GTEST_SKIP() << "No macOS mainMenu available in current test environment"; + } + + // File Menu (non-edit menu) + NSMenuItem* fileTop = [[NSMenuItem alloc] initWithTitle:@"File" action:nil keyEquivalent:@""]; + NSMenu* fileSub = [[NSMenu alloc] initWithTitle:@"File"]; + m_saveItem = [[NSMenuItem alloc] initWithTitle:@"Save" action:@selector(fileSaveAction:) keyEquivalent:@"s"]; + [m_saveItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand]; + [m_saveItem setTarget:m_mockTarget]; + [fileSub addItem:m_saveItem]; + [fileTop setSubmenu:fileSub]; + [m_mainMenu addItem:fileTop]; + + // Edit Menu + NSMenuItem* editTop = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""]; + NSMenu* editSub = [[NSMenu alloc] initWithTitle:@"Edit"]; + + m_undoItem = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"]; + [m_undoItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand]; + [m_undoItem setTarget:m_mockTarget]; + [editSub addItem:m_undoItem]; + + m_redoItem = [[NSMenuItem alloc] initWithTitle:@"Redo" action:@selector(triggerAction:) keyEquivalent:@"Z"]; + [m_redoItem setKeyEquivalentModifierMask:(NSEventModifierFlagCommand | NSEventModifierFlagShift)]; + [m_redoItem setTarget:m_mockTarget]; + [editSub addItem:m_redoItem]; + + m_cutItem = [[NSMenuItem alloc] initWithTitle:@"Cut" action:@selector(triggerAction:) keyEquivalent:@"x"]; + [m_cutItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand]; + [m_cutItem setTarget:m_mockTarget]; + [editSub addItem:m_cutItem]; + + m_copyItem = [[NSMenuItem alloc] initWithTitle:@"Copy" action:@selector(triggerAction:) keyEquivalent:@"c"]; + [m_copyItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand]; + [m_copyItem setTarget:m_mockTarget]; + [editSub addItem:m_copyItem]; + + m_pasteItem = [[NSMenuItem alloc] initWithTitle:@"Paste" action:@selector(triggerAction:) keyEquivalent:@"v"]; + [m_pasteItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand]; + [m_pasteItem setTarget:m_mockTarget]; + [editSub addItem:m_pasteItem]; + + m_selectAllItem = [[NSMenuItem alloc] initWithTitle:@"Select All" action:@selector(triggerAction:) keyEquivalent:@"a"]; + [m_selectAllItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand]; + [m_selectAllItem setTarget:m_mockTarget]; + [editSub addItem:m_selectAllItem]; + + [editTop setSubmenu:editSub]; + [m_mainMenu addItem:editTop]; + + std::map defaultStructure = { + { muse::MacOSInteractiveHelper::EditAction::Undo, 0 }, + { muse::MacOSInteractiveHelper::EditAction::Redo, 1 }, + { muse::MacOSInteractiveHelper::EditAction::Cut, 2 }, + { muse::MacOSInteractiveHelper::EditAction::Copy, 3 }, + { muse::MacOSInteractiveHelper::EditAction::Paste, 4 }, + { muse::MacOSInteractiveHelper::EditAction::SelectAll, 5 }, + }; + muse::MacOSInteractiveHelper::setEditMenuIndex(1); + muse::MacOSInteractiveHelper::setEditMenuStructure(defaultStructure); + } + + void TearDown() override + { + muse::MacOSInteractiveHelper::setEditMenuIndex(-1); + muse::MacOSInteractiveHelper::setEditMenuStructure({}); + [NSApp setMainMenu:nil]; + [m_pool drain]; + } + + NSAutoreleasePool* m_pool = nil; + MockMenuTarget* m_mockTarget = nil; + NSMenu* m_mainMenu = nil; + NSMenuItem* m_saveItem = nil; + NSMenuItem* m_undoItem = nil; + NSMenuItem* m_redoItem = nil; + NSMenuItem* m_cutItem = nil; + NSMenuItem* m_copyItem = nil; + NSMenuItem* m_pasteItem = nil; + NSMenuItem* m_selectAllItem = nil; +}; + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_SetsNilTargetAndStandardActions) +{ + // Before scope: All edit items target the mock object with triggerAction: + EXPECT_EQ([m_undoItem target], m_mockTarget); + EXPECT_EQ([m_undoItem action], @selector(triggerAction:)); + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + + // Inside scope: Edit items must target First Responder (nil) with standard selectors and key equivalents, and image is nil + EXPECT_EQ([m_undoItem target], nil); + EXPECT_EQ([m_undoItem action], @selector(undo:)); + EXPECT_STREQ([[m_undoItem keyEquivalent] UTF8String], "z"); + EXPECT_TRUE([m_undoItem isEnabled]); + if ([m_undoItem respondsToSelector:@selector(preferredImageVisibility)]) { + NSInteger visibility = ((NSInteger (*)(id, SEL)) objc_msgSend)(m_undoItem, @selector(preferredImageVisibility)); + EXPECT_EQ(visibility, 1); // 1 = NSMenuItemImageVisibilityHidden + } + if ([m_copyItem respondsToSelector:@selector(_hasActionImage)]) { + BOOL hasImg = ((BOOL (*)(id, SEL)) objc_msgSend)(m_copyItem, @selector(_hasActionImage)); + EXPECT_FALSE(hasImg); + } + if ([m_copyItem respondsToSelector:@selector(_actionImage)]) { + NSImage* actImg = ((NSImage * (*)(id, SEL)) objc_msgSend)(m_copyItem, @selector(_actionImage)); + EXPECT_EQ(actImg, nil); + } + } +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_RestoresOriginalTargetsAndActions) +{ + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + EXPECT_EQ([m_undoItem target], nil); + } + + // After scope exits: All items must be restored to original target, action, and keyEquivalent + EXPECT_EQ([m_undoItem target], m_mockTarget); + EXPECT_EQ([m_undoItem action], @selector(triggerAction:)); + EXPECT_STREQ([[m_undoItem keyEquivalent] UTF8String], "z"); + + EXPECT_EQ([m_redoItem target], m_mockTarget); + EXPECT_EQ([m_redoItem action], @selector(triggerAction:)); + EXPECT_STREQ([[m_redoItem keyEquivalent] UTF8String], "Z"); + + EXPECT_EQ([m_cutItem target], m_mockTarget); + EXPECT_EQ([m_cutItem action], @selector(triggerAction:)); + EXPECT_STREQ([[m_cutItem keyEquivalent] UTF8String], "x"); + + EXPECT_EQ([m_copyItem target], m_mockTarget); + EXPECT_EQ([m_copyItem action], @selector(triggerAction:)); + EXPECT_STREQ([[m_copyItem keyEquivalent] UTF8String], "c"); + + EXPECT_EQ([m_pasteItem target], m_mockTarget); + EXPECT_EQ([m_pasteItem action], @selector(triggerAction:)); + EXPECT_STREQ([[m_pasteItem keyEquivalent] UTF8String], "v"); + + EXPECT_EQ([m_selectAllItem target], m_mockTarget); + EXPECT_EQ([m_selectAllItem action], @selector(triggerAction:)); + EXPECT_STREQ([[m_selectAllItem keyEquivalent] UTF8String], "a"); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesNestedScopes) +{ + { + muse::MacOSInteractiveHelper::NativeDialogScope outerScope; + EXPECT_EQ([m_pasteItem target], nil); + + { + muse::MacOSInteractiveHelper::NativeDialogScope innerScope; + EXPECT_EQ([m_pasteItem target], nil); + } + + // Still in outer scope + EXPECT_EQ([m_pasteItem target], nil); + EXPECT_EQ([m_pasteItem action], @selector(paste:)); + } + + // Outermost scope exited -> restored + EXPECT_EQ([m_pasteItem target], m_mockTarget); + EXPECT_EQ([m_pasteItem action], @selector(triggerAction:)); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_PreservesNonEditItems) +{ + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + + // Non-edit items (such as Save) must remain completely untouched + EXPECT_EQ([m_saveItem target], m_mockTarget); + EXPECT_EQ([m_saveItem action], @selector(fileSaveAction:)); + } + + EXPECT_EQ([m_saveItem target], m_mockTarget); + EXPECT_EQ([m_saveItem action], @selector(fileSaveAction:)); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesEmptyKeyEquivalentMenuItems) +{ + // Simulate main branch where Qt constructs menu items without shortcuts (keyEquivalent is empty) + NSMenuItem* editTop = [m_mainMenu itemArray][1]; + NSMenu* editSub = [editTop submenu]; + [editSub removeAllItems]; + + std::map structure = { + { muse::MacOSInteractiveHelper::EditAction::Undo, 0 }, + { muse::MacOSInteractiveHelper::EditAction::Redo, 1 }, + { muse::MacOSInteractiveHelper::EditAction::Paste, 2 }, + }; + muse::MacOSInteractiveHelper::setEditMenuStructure(structure); + + NSMenuItem* undo = [[NSMenuItem alloc] initWithTitle:@"Undo\t" action:@selector(triggerAction:) keyEquivalent:@""]; + [undo setTarget:m_mockTarget]; + [editSub addItem:undo]; + + NSMenuItem* redo = [[NSMenuItem alloc] initWithTitle:@"Redo\t" action:@selector(triggerAction:) keyEquivalent:@""]; + [redo setTarget:m_mockTarget]; + [editSub addItem:redo]; + + NSMenuItem* paste = [[NSMenuItem alloc] initWithTitle:@"&Paste\t" action:@selector(triggerAction:) keyEquivalent:@""]; + [paste setTarget:m_mockTarget]; + [editSub addItem:paste]; + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + + EXPECT_EQ([undo target], nil); + EXPECT_EQ([undo action], @selector(undo:)); + EXPECT_STREQ([[undo keyEquivalent] UTF8String], "z"); + EXPECT_TRUE([undo isEnabled]); + + EXPECT_EQ([redo target], nil); + EXPECT_EQ([redo action], @selector(redo:)); + EXPECT_STREQ([[redo keyEquivalent] UTF8String], "Z"); + EXPECT_TRUE([redo isEnabled]); + + EXPECT_EQ([paste target], nil); + EXPECT_EQ([paste action], @selector(paste:)); + EXPECT_STREQ([[paste keyEquivalent] UTF8String], "v"); + EXPECT_TRUE([paste isEnabled]); + } + + // After scope: original empty keyEquivalent and target restored + EXPECT_EQ([undo target], m_mockTarget); + EXPECT_STREQ([[undo keyEquivalent] UTF8String], ""); + + EXPECT_EQ([redo target], m_mockTarget); + EXPECT_STREQ([[redo keyEquivalent] UTF8String], ""); + + EXPECT_EQ([paste target], m_mockTarget); + EXPECT_STREQ([[paste keyEquivalent] UTF8String], ""); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesUppercaseZRedoWithoutShift) +{ + // AppKit uppercase "Z" implies Shift even if modifierMask is only Command + NSMenuItem* editTop = [m_mainMenu itemArray][1]; + NSMenu* editSub = [editTop submenu]; + [editSub removeAllItems]; + + std::map structure = { + { muse::MacOSInteractiveHelper::EditAction::Redo, 0 }, + }; + muse::MacOSInteractiveHelper::setEditMenuStructure(structure); + + NSMenuItem* redoItem = [[NSMenuItem alloc] initWithTitle:@"Redo" action:@selector(triggerAction:) keyEquivalent:@"Z"]; + [redoItem setKeyEquivalentModifierMask:NSEventModifierFlagCommand]; // no explicit Shift flag + [redoItem setTarget:m_mockTarget]; + [editSub addItem:redoItem]; + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + EXPECT_EQ([redoItem target], nil); + EXPECT_EQ([redoItem action], @selector(redo:)); + EXPECT_STREQ([[redoItem keyEquivalent] UTF8String], "Z"); + EXPECT_TRUE([redoItem isEnabled]); + } + + EXPECT_EQ([redoItem target], m_mockTarget); + EXPECT_EQ([redoItem action], @selector(triggerAction:)); + EXPECT_STREQ([[redoItem keyEquivalent] UTF8String], "Z"); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_PreservesPasteSpecialWithDifferentModifier) +{ + NSMenuItem* editTop = [m_mainMenu itemArray][1]; + NSMenu* editSub = [editTop submenu]; + [editSub removeAllItems]; + + std::map structure = { + { muse::MacOSInteractiveHelper::EditAction::Paste, 0 }, + }; + muse::MacOSInteractiveHelper::setEditMenuStructure(structure); + + NSMenuItem* normalPaste = [[NSMenuItem alloc] initWithTitle:@"&Paste\t" action:@selector(triggerAction:) keyEquivalent:@""]; + [normalPaste setTarget:m_mockTarget]; + [editSub addItem:normalPaste]; + + NSMenuItem* specialPaste = [[NSMenuItem alloc] initWithTitle:@"Paste Special\t" action:@selector(triggerAction:) keyEquivalent:@"v"]; + [specialPaste setKeyEquivalentModifierMask:(NSEventModifierFlagCommand | NSEventModifierFlagOption)]; + [specialPaste setTarget:m_mockTarget]; + [editSub addItem:specialPaste]; + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + + EXPECT_EQ([normalPaste target], nil); + EXPECT_EQ([normalPaste action], @selector(paste:)); + EXPECT_STREQ([[normalPaste keyEquivalent] UTF8String], "v"); + EXPECT_EQ([normalPaste keyEquivalentModifierMask], NSEventModifierFlagCommand); + EXPECT_TRUE([normalPaste isEnabled]); + + // Paste Special must remain completely untouched + EXPECT_EQ([specialPaste target], m_mockTarget); + EXPECT_EQ([specialPaste action], @selector(triggerAction:)); + EXPECT_STREQ([[specialPaste keyEquivalent] UTF8String], "v"); + EXPECT_EQ([specialPaste keyEquivalentModifierMask], (NSEventModifierFlagCommand | NSEventModifierFlagOption)); + } + + EXPECT_EQ([normalPaste target], m_mockTarget); + EXPECT_STREQ([[normalPaste keyEquivalent] UTF8String], ""); + EXPECT_EQ([specialPaste target], m_mockTarget); + EXPECT_EQ([specialPaste keyEquivalentModifierMask], (NSEventModifierFlagCommand | NSEventModifierFlagOption)); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_CreatesAndRemovesFallbackItemsWhenActionsMissing) +{ + // Configure menu with only Undo (index 0) provided. Redo, Cut, Copy, Paste, Select All are missing. + std::map structure = { + { muse::MacOSInteractiveHelper::EditAction::Undo, 0 }, + }; + muse::MacOSInteractiveHelper::setEditMenuStructure(structure); + muse::MacOSInteractiveHelper::setEditMenuIndex(1); + + NSMenuItem* editTop = [m_mainMenu itemArray][1]; + NSMenu* editSub = [editTop submenu]; + [editSub removeAllItems]; + + NSMenuItem* undo = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"]; + [undo setTarget:m_mockTarget]; + [editSub addItem:undo]; + + NSUInteger initialCount = [[editSub itemArray] count]; // 1 item (Undo) + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + + // 5 fallback items should be created for missing actions (Redo, Cut, Copy, Paste, Select All) + EXPECT_EQ([[editSub itemArray] count], initialCount + 5); + + // Verify fallback items target First Responder, are enabled, and hidden from view + for (NSUInteger i = initialCount; i < [[editSub itemArray] count]; ++i) { + NSMenuItem* fallback = [editSub itemArray][i]; + EXPECT_EQ([fallback target], nil); + EXPECT_TRUE([fallback isEnabled]); + EXPECT_TRUE([fallback isHidden]); + } + } + + // On scope exit, all 5 fallback items must be removed cleanly + EXPECT_EQ([[editSub itemArray] count], initialCount); + EXPECT_EQ([undo target], m_mockTarget); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_MatchesCustomStructureAndCreatesZeroTemporaryItems) +{ + std::map structure = { + { muse::MacOSInteractiveHelper::EditAction::Undo, 0 }, + { muse::MacOSInteractiveHelper::EditAction::Redo, 1 }, + { muse::MacOSInteractiveHelper::EditAction::Cut, 4 }, + { muse::MacOSInteractiveHelper::EditAction::Copy, 5 }, + { muse::MacOSInteractiveHelper::EditAction::Paste, 6 }, + { muse::MacOSInteractiveHelper::EditAction::SelectAll, 8 }, + }; + muse::MacOSInteractiveHelper::setEditMenuStructure(structure); + muse::MacOSInteractiveHelper::setEditMenuIndex(2); + + [m_mainMenu removeAllItems]; + + NSMenuItem* appItem = [[NSMenuItem alloc] initWithTitle:@"MuseScore Studio" action:nil keyEquivalent:@""]; + [appItem setSubmenu:[[NSMenu alloc] initWithTitle:@"MuseScore Studio"]]; + [m_mainMenu addItem:appItem]; + + NSMenuItem* fileItem = [[NSMenuItem alloc] initWithTitle:@"文件 (F)" action:nil keyEquivalent:@""]; + [fileItem setSubmenu:[[NSMenu alloc] initWithTitle:@"文件 (F)"]]; + [m_mainMenu addItem:fileItem]; + + NSMenuItem* editItem = [[NSMenuItem alloc] initWithTitle:@"编辑 (E)" action:nil keyEquivalent:@""]; + NSMenu* editSub = [[NSMenu alloc] initWithTitle:@"编辑 (E)"]; + + NSMenuItem* zhUndo = [[NSMenuItem alloc] initWithTitle:@"撤消“粘贴”" action:@selector(triggerAction:) keyEquivalent:@"z"]; + [zhUndo setTarget:m_mockTarget]; + [editSub addItem:zhUndo]; + + NSMenuItem* zhRedo = [[NSMenuItem alloc] initWithTitle:@"恢复" action:@selector(triggerAction:) keyEquivalent:@"Z"]; + [zhRedo setTarget:m_mockTarget]; + [editSub addItem:zhRedo]; + + NSMenuItem* zhHistory = [[NSMenuItem alloc] initWithTitle:@"历史 (H)" action:@selector(triggerAction:) keyEquivalent:@""]; + [zhHistory setTarget:m_mockTarget]; + [editSub addItem:zhHistory]; + + [editSub addItem:[NSMenuItem separatorItem]]; + + NSMenuItem* zhCut = [[NSMenuItem alloc] initWithTitle:@"剪切 (T)" action:@selector(triggerAction:) keyEquivalent:@"x"]; + [zhCut setTarget:m_mockTarget]; + [editSub addItem:zhCut]; + + NSMenuItem* zhCopy = [[NSMenuItem alloc] initWithTitle:@"拷贝 (C)" action:@selector(triggerAction:) keyEquivalent:@"c"]; + [zhCopy setTarget:m_mockTarget]; + [editSub addItem:zhCopy]; + + NSMenuItem* zhPaste = [[NSMenuItem alloc] initWithTitle:@"粘贴 (E)" action:@selector(triggerAction:) keyEquivalent:@"v"]; + [zhPaste setTarget:m_mockTarget]; + [editSub addItem:zhPaste]; + + [editSub addItem:[NSMenuItem separatorItem]]; + + NSMenuItem* zhSelectAll = [[NSMenuItem alloc] initWithTitle:@"全选 (A)" action:@selector(triggerAction:) keyEquivalent:@"a"]; + [zhSelectAll setTarget:m_mockTarget]; + [editSub addItem:zhSelectAll]; + + [editItem setSubmenu:editSub]; + [m_mainMenu addItem:editItem]; + + NSUInteger initialCount = [[editSub itemArray] count]; + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + + // Verify zero temporary items were created in editSub + EXPECT_EQ([[editSub itemArray] count], initialCount); + + // Verify existing Chinese items were modified in-place + EXPECT_EQ([zhUndo target], nil); + EXPECT_EQ([zhUndo action], @selector(undo:)); + EXPECT_STREQ([[zhUndo keyEquivalent] UTF8String], "z"); + EXPECT_TRUE([zhUndo isEnabled]); + + EXPECT_EQ([zhRedo target], nil); + EXPECT_EQ([zhRedo action], @selector(redo:)); + EXPECT_STREQ([[zhRedo keyEquivalent] UTF8String], "Z"); + EXPECT_TRUE([zhRedo isEnabled]); + + EXPECT_EQ([zhCut target], nil); + EXPECT_EQ([zhCut action], @selector(cut:)); + EXPECT_STREQ([[zhCut keyEquivalent] UTF8String], "x"); + EXPECT_TRUE([zhCut isEnabled]); + + EXPECT_EQ([zhCopy target], nil); + EXPECT_EQ([zhCopy action], @selector(copy:)); + EXPECT_STREQ([[zhCopy keyEquivalent] UTF8String], "c"); + EXPECT_TRUE([zhCopy isEnabled]); + + EXPECT_EQ([zhPaste target], nil); + EXPECT_EQ([zhPaste action], @selector(paste:)); + EXPECT_STREQ([[zhPaste keyEquivalent] UTF8String], "v"); + EXPECT_TRUE([zhPaste isEnabled]); + + EXPECT_EQ([zhSelectAll target], nil); + EXPECT_EQ([zhSelectAll action], @selector(selectAll:)); + EXPECT_STREQ([[zhSelectAll keyEquivalent] UTF8String], "a"); + EXPECT_TRUE([zhSelectAll isEnabled]); + + // Unmodified items like 历史 (H) remain untouched + EXPECT_EQ([zhHistory target], m_mockTarget); + } + + // Post-scope: original state restored, item count identical + EXPECT_EQ([[editSub itemArray] count], initialCount); + EXPECT_EQ([zhUndo target], m_mockTarget); + EXPECT_STREQ([[zhUndo keyEquivalent] UTF8String], "z"); + EXPECT_EQ([zhPaste target], m_mockTarget); + EXPECT_STREQ([[zhPaste keyEquivalent] UTF8String], "v"); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_MaintainsTargetWhenEditMenuIsClickedAndTracked) +{ + [m_mainMenu removeAllItems]; + + NSMenuItem* appItem = [[NSMenuItem alloc] initWithTitle:@"MuseScore Studio" action:nil keyEquivalent:@""]; + [appItem setSubmenu:[[NSMenu alloc] initWithTitle:@"MuseScore Studio"]]; + [m_mainMenu addItem:appItem]; + + NSMenuItem* editItem = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""]; + NSMenu* editSub = [[NSMenu alloc] initWithTitle:@"Edit"]; + muse::MacOSInteractiveHelper::setEditMenuIndex(1); + + NSMenuItem* undo = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@""]; + [undo setTarget:m_mockTarget]; + [editSub addItem:undo]; + + NSMenuItem* redo = [[NSMenuItem alloc] initWithTitle:@"Redo" action:@selector(triggerAction:) keyEquivalent:@""]; + [redo setTarget:m_mockTarget]; + [editSub addItem:redo]; + + [editItem setSubmenu:editSub]; + [m_mainMenu addItem:editItem]; + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + + EXPECT_EQ([undo target], nil); + EXPECT_EQ([redo target], nil); + + // Simulate QML/Qt resetting the target when onAboutToShow executes + [undo setTarget:m_mockTarget]; + [redo setTarget:m_mockTarget]; + [editSub setAutoenablesItems:YES]; + + EXPECT_EQ([undo target], m_mockTarget); + + // Simulate user clicking the Edit menu -> AppKit posts NSMenuDidEndTrackingNotification when menu interaction completes + [[NSNotificationCenter defaultCenter] postNotificationName:NSMenuDidEndTrackingNotification object:editSub]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + + // Verify observer automatically restored target = nil and autoenablesItems = NO + EXPECT_EQ([undo target], nil); + EXPECT_EQ([redo target], nil); + EXPECT_FALSE([editSub autoenablesItems]); + } + + EXPECT_EQ([undo target], m_mockTarget); + EXPECT_EQ([redo target], m_mockTarget); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesMenuRecreationDuringTracking) +{ + [m_mainMenu removeAllItems]; + + NSMenuItem* appItem = [[NSMenuItem alloc] initWithTitle:@"MuseScore Studio" action:nil keyEquivalent:@""]; + [appItem setSubmenu:[[NSMenu alloc] initWithTitle:@"MuseScore Studio"]]; + [m_mainMenu addItem:appItem]; + + NSMenuItem* editItem = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""]; + NSMenu* editSub = [[NSMenu alloc] initWithTitle:@"Edit"]; + muse::MacOSInteractiveHelper::setEditMenuIndex(1); + + NSMenuItem* undo1 = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"]; + [undo1 setTarget:m_mockTarget]; + [editSub addItem:undo1]; + + [editItem setSubmenu:editSub]; + [m_mainMenu addItem:editItem]; + + NSMenuItem* undo2 = nil; + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + EXPECT_EQ([undo1 target], nil); + + // Simulate Qt completely clearing the menu and adding brand new NSMenuItem objects during menu tracking + [editSub removeAllItems]; + undo2 = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"]; + [undo2 setTarget:m_mockTarget]; + [editSub addItem:undo2]; + [editSub setAutoenablesItems:YES]; + + EXPECT_EQ([undo2 target], m_mockTarget); + + // End tracking notification fires when user closes menu + [[NSNotificationCenter defaultCenter] postNotificationName:NSMenuDidEndTrackingNotification object:editSub]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + + // Observer must dynamically match undo2 and recreate missing fallbacks (Redo, Cut, Copy, Paste, Select All) + EXPECT_EQ([undo2 target], nil); + EXPECT_FALSE([editSub autoenablesItems]); + EXPECT_EQ([[editSub itemArray] count], 6); + + for (NSUInteger i = 1; i < [[editSub itemArray] count]; ++i) { + NSMenuItem* fallback = [editSub itemArray][i]; + EXPECT_EQ([fallback target], nil); + EXPECT_TRUE([fallback isEnabled]); + EXPECT_TRUE([fallback isHidden]); + } + } + + // On scope exit, fallbacks must be removed cleanly and undo2 restored to original target + EXPECT_EQ([[editSub itemArray] count], 1); + EXPECT_EQ([undo2 target], m_mockTarget); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesSubmenuReplacementDuringTracking) +{ + [m_mainMenu removeAllItems]; + + NSMenuItem* appItem = [[NSMenuItem alloc] initWithTitle:@"MuseScore Studio" action:nil keyEquivalent:@""]; + [appItem setSubmenu:[[NSMenu alloc] initWithTitle:@"MuseScore Studio"]]; + [m_mainMenu addItem:appItem]; + + NSMenuItem* editItem = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""]; + NSMenu* oldEditSub = [[NSMenu alloc] initWithTitle:@"Edit"]; + muse::MacOSInteractiveHelper::setEditMenuIndex(1); + + NSMenuItem* undo1 = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"]; + [undo1 setTarget:m_mockTarget]; + [oldEditSub addItem:undo1]; + + [editItem setSubmenu:oldEditSub]; + [m_mainMenu addItem:editItem]; + + NSMenuItem* undo2 = nil; + NSMenu* newEditSub = nil; + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + EXPECT_EQ([undo1 target], nil); + + // Simulate replacing the entire NSMenu instance of the Edit submenu + newEditSub = [[NSMenu alloc] initWithTitle:@"Edit"]; + undo2 = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"]; + [undo2 setTarget:m_mockTarget]; + [newEditSub addItem:undo2]; + [editItem setSubmenu:newEditSub]; + + EXPECT_EQ([undo2 target], m_mockTarget); + + // Tracking notification fires -> applyScopeTransformations re-resolves the live submenu + [[NSNotificationCenter defaultCenter] postNotificationName:NSMenuDidEndTrackingNotification object:newEditSub]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + + EXPECT_EQ([undo2 target], nil); + EXPECT_FALSE([newEditSub autoenablesItems]); + EXPECT_EQ([[newEditSub itemArray] count], 6); + } + + // On scope exit, fallbacks removed and target restored + EXPECT_EQ([[newEditSub itemArray] count], 1); + EXPECT_EQ([undo2 target], m_mockTarget); +} + +TEST_F(MacOSInteractiveHelperTest, NativeDialogScope_HandlesMainMenuRemovalOrIndexOutOfBoundsDuringTracking) +{ + [m_mainMenu removeAllItems]; + + NSMenuItem* appItem = [[NSMenuItem alloc] initWithTitle:@"MuseScore Studio" action:nil keyEquivalent:@""]; + [appItem setSubmenu:[[NSMenu alloc] initWithTitle:@"MuseScore Studio"]]; + [m_mainMenu addItem:appItem]; + + NSMenuItem* editItem = [[NSMenuItem alloc] initWithTitle:@"Edit" action:nil keyEquivalent:@""]; + NSMenu* editSub = [[NSMenu alloc] initWithTitle:@"Edit"]; + muse::MacOSInteractiveHelper::setEditMenuIndex(1); + + NSMenuItem* undo = [[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(triggerAction:) keyEquivalent:@"z"]; + [undo setTarget:m_mockTarget]; + [editSub addItem:undo]; + + [editItem setSubmenu:editSub]; + [m_mainMenu addItem:editItem]; + + { + muse::MacOSInteractiveHelper::NativeDialogScope scope; + EXPECT_EQ([undo target], nil); + + // Simulate main menu items being stripped away (e.g. during a full UI / window teardown) + [m_mainMenu removeAllItems]; + + // Tracking notification fires while index 1 is now completely out of bounds + [[NSNotificationCenter defaultCenter] postNotificationName:NSMenuDidEndTrackingNotification object:nil]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + + // Must safely release the stale s_editMenu, restore its autoenablesItems, and not crash + } +}