diff --git a/VoidLink.xcodeproj/project.pbxproj b/VoidLink.xcodeproj/project.pbxproj index 771315f8..99379944 100644 --- a/VoidLink.xcodeproj/project.pbxproj +++ b/VoidLink.xcodeproj/project.pbxproj @@ -35,6 +35,8 @@ 18E4BCEA2E891895000A8657 /* MotionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E4BCE92E891895000A8657 /* MotionHandler.swift */; }; 18FCADAF2ECAC23200787A96 /* PencilHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FCADAE2ECAC23200787A96 /* PencilHandler.swift */; }; 2A7113FD2F5A6C65001D852D /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7113FC2F5A6C65001D852D /* ThemeManager.swift */; }; + 2AAA4E822F7666C5001E20DC /* ControllerUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A765BC2ECD51FB004784AA /* ControllerUtil.swift */; }; + 2AAA4E842F7666EE001E20DC /* SwiftLocalizationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E1CDF92C4FE83400547904 /* SwiftLocalizationHelper.swift */; }; 2AC1E3832F507D80004F76C0 /* GenericUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AC1E3822F507D80004F76C0 /* GenericUtils.swift */; }; 840751B32BF49C050096B657 /* CustomTapGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 840751B22BF49C050096B657 /* CustomTapGestureRecognizer.m */; }; 84203F232C5F24C00032EEED /* OnScreenWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84203F222C5F24C00032EEED /* OnScreenWidgetView.swift */; }; @@ -199,6 +201,7 @@ 18D43D7E2F050240008C326F /* PressureCurve.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PressureCurve.swift; sourceTree = ""; }; 18E4BCE92E891895000A8657 /* MotionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MotionHandler.swift; sourceTree = ""; }; 18FCADAE2ECAC23200787A96 /* PencilHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PencilHandler.swift; sourceTree = ""; }; + 2A039E562F72892B00546768 /* VoidLinkSwiftUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoidLinkSwiftUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2A7113FC2F5A6C65001D852D /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; 2AC1E3822F507D80004F76C0 /* GenericUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericUtils.swift; sourceTree = ""; }; 693B3A9A218638CD00982F7B /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -414,11 +417,39 @@ FBDE86E519F82297001C18A8 /* UIAppView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UIAppView.m; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 2A93D5662F7D016C00C2207F /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + WidgetPickerViewController.swift, + ); + target = 2A039E552F72892B00546768 /* VoidLinkSwiftUI */; + }; + 2AAA4E862F76BDC6001E20DC /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + AbstractGamepadView.swift, + VirtualKeyboardView.swift, + WidgetPickerView.swift, + WidgetPickerViewController.swift, + ); + target = FB290CED19B2C406004C83CF /* VoidLink */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 18A0EE282EFBCBFB00BA853A /* SVGs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SVGs; sourceTree = ""; }; + 2A039E572F72892B00546768 /* VoidLinkSwiftUI */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2AAA4E862F76BDC6001E20DC /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 2A93D5662F7D016C00C2207F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = VoidLinkSwiftUI; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 2A039E532F72892B00546768 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; FB290CEB19B2C406004C83CF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -590,6 +621,7 @@ 98AB2E7F1CAD46830089BB98 /* moonlight-common.xcodeproj */, FB290CF919B2C406004C83CF /* VoidLink */, FB1A67542132419700507771 /* Moonlight TV */, + 2A039E572F72892B00546768 /* VoidLinkSwiftUI */, FB290CF019B2C406004C83CF /* Frameworks */, FB290CEF19B2C406004C83CF /* Products */, ); @@ -599,6 +631,7 @@ isa = PBXGroup; children = ( FB290CEE19B2C406004C83CF /* VoidLink.app */, + 2A039E562F72892B00546768 /* VoidLinkSwiftUI.app */, ); name = Products; sourceTree = ""; @@ -938,6 +971,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 2A039E552F72892B00546768 /* VoidLinkSwiftUI */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A039E5E2F72892C00546768 /* Build configuration list for PBXNativeTarget "VoidLinkSwiftUI" */; + buildPhases = ( + 2A039E522F72892B00546768 /* Sources */, + 2A039E532F72892B00546768 /* Frameworks */, + 2A039E542F72892B00546768 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 2A039E572F72892B00546768 /* VoidLinkSwiftUI */, + ); + name = VoidLinkSwiftUI; + packageProductDependencies = ( + ); + productName = VoidLinkSwiftUI; + productReference = 2A039E562F72892B00546768 /* VoidLinkSwiftUI.app */; + productType = "com.apple.product-type.application"; + }; FB290CED19B2C406004C83CF /* VoidLink */ = { isa = PBXNativeTarget; buildConfigurationList = FB290D2019B2C406004C83CF /* Build configuration list for PBXNativeTarget "VoidLink" */; @@ -965,10 +1020,13 @@ FB290CE619B2C406004C83CF /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 0730; + LastSwiftUpdateCheck = 2620; LastUpgradeCheck = 1400; ORGANIZATIONNAME = "Moonlight Game Streaming Project"; TargetAttributes = { + 2A039E552F72892B00546768 = { + CreatedOnToolsVersion = 26.2; + }; FB290CED19B2C406004C83CF = { LastSwiftMigration = 1140; }; @@ -1002,6 +1060,7 @@ projectRoot = ""; targets = ( FB290CED19B2C406004C83CF /* VoidLink */, + 2A039E552F72892B00546768 /* VoidLinkSwiftUI */, ); }; /* End PBXProject section */ @@ -1024,6 +1083,13 @@ /* End PBXReferenceProxy section */ /* Begin PBXResourcesBuildPhase section */ + 2A039E542F72892B00546768 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; FB290CEC19B2C406004C83CF /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1043,6 +1109,15 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 2A039E522F72892B00546768 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2AAA4E842F7666EE001E20DC /* SwiftLocalizationHelper.swift in Sources */, + 2AAA4E822F7666C5001E20DC /* ControllerUtil.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FB290CEA19B2C406004C83CF /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1206,6 +1281,102 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 2A039E5F2F72892C00546768 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = FRL23UV4N9; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.trzj.VoidLinkSwiftUI; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2A039E602F72892C00546768 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = FRL23UV4N9; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.trzj.VoidLinkSwiftUI; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; FB290D1E19B2C406004C83CF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1451,6 +1622,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 2A039E5E2F72892C00546768 /* Build configuration list for PBXNativeTarget "VoidLinkSwiftUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A039E5F2F72892C00546768 /* Debug */, + 2A039E602F72892C00546768 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; FB290CE919B2C406004C83CF /* Build configuration list for PBXProject "VoidLink" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/VoidLink.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/VoidLink.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..54782e32 --- /dev/null +++ b/VoidLink.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/VoidLink/Input/CommandManager.swift b/VoidLink/Input/CommandManager.swift index a8ab3074..1f6292a5 100644 --- a/VoidLink/Input/CommandManager.swift +++ b/VoidLink/Input/CommandManager.swift @@ -163,8 +163,11 @@ import UIKit "BRUSH": 0xFF, "ERASER": 0xFF, "CTRL": 0x11, // VK_CONTROL + "RCTRL": 0xA3, // VK_RCONTROL "SHIFT": 0x10, // VK_SHIFT + "RSHIFT": 0xA1, // VK_RSHIFT "ALT": 0x12, // VK_MENU + "RALT": 0xA5, // VK_MENU "F1": 0x70, // VK_F1 "F2": 0x71, // VK_F2 "F3": 0x72, // VK_F3 @@ -300,111 +303,131 @@ import UIKit "LAUNCHAPP2": 0xB7, // VK_LAUNCH_APP2 "WIN": 0x5B, "LEFT_WIN": 0x5B, // VK_LWIN + "LEFTWIN": 0x5B, // VK_LWIN + "LWIN": 0x5B, // VK_LWIN "RIGHT_WIN": 0x5C, // VK_RWIN "RIGHTWIN": 0x5C, // VK_RWIN + "RWIN": 0x5C, // VK_RWIN "APPS": 0x5D, // VK_APPS // macOS Key Codes - "CMD": 0x37, // ⌘ Command - "OPT": 0x3A, // ⌥ Option - "CONTROL": 0x3B, // ⌃ Control - "FUNCTION": 0x3F, // fn - "SHIFTMAC": 0x38, // ⇧ Shift - "DELETEMAC": 0x75, // Forward Delete - "RETURNMAC": 0x24, // Return - "ENTERMAC": 0x4C, // Enter - "ESCAPEMAC": 0x35, // Escape - "TABMAC": 0x30, // Tab - "SPACEMAC": 0x31, // Space - "UPARRMAC": 0x7E, // Up Arrow - "DOWNARRMAC": 0x7D, // Down Arrow - "LEFTARRMAC": 0x7B, // Left Arrow - "RIGHTARRMAC": 0x7C, // Right Arrow - "F1MAC": 0x7A, // F1 - "F2MAC": 0x78, // F2 - "F3MAC": 0x63, // F3 - "F4MAC": 0x76, // F4 - "F5MAC": 0x60, // F5 - "F6MAC": 0x61, // F6 - "F7MAC": 0x62, // F7 - "F8MAC": 0x64, // F8 - "F9MAC": 0x65, // F9 - "F10MAC": 0x6D, // F10 - "F11MAC": 0x67, // F11 - "F12MAC": 0x6F, // F12 - "0MAC": 0x52, // 0 - "1MAC": 0x53, // 1 - "2MAC": 0x54, // 2 - "3MAC": 0x55, // 3 - "4MAC": 0x56, // 4 - "5MAC": 0x57, // 5 - "6MAC": 0x58, // 6 - "7MAC": 0x59, // 7 - "8MAC": 0x5A, // 8 - "9MAC": 0x5B, // 9 - "NUMPAD0MAC": 0x4F, // Numpad 0 - "NUMPAD1MAC": 0x50, // Numpad 1 - "NUMPAD2MAC": 0x51, // Numpad 2 - "NUMPAD3MAC": 0x52, // Numpad 3 - "NUMPAD4MAC": 0x53, // Numpad 4 - "NUMPAD5MAC": 0x54, // Numpad 5 - "NUMPAD6MAC": 0x55, // Numpad 6 - "NUMPAD7MAC": 0x56, // Numpad 7 - "NUMPAD8MAC": 0x57, // Numpad 8 - "NUMPAD9MAC": 0x58, // Numpad 9 - "NUMPADADDMAC": 0x45, // Numpad Add - "NUMPADSUBTRACTMAC": 0x4A, // Numpad Subtract - "NUMPADMULTIPLYMAC": 0x43, // Numpad Multiply - "NUMPADDIVIDEMAC": 0x4B, // Numpad Divide - "NUMPADDECIMALMAC": 0x41, // Numpad Decimal - "SHIFT_MAC": 0x38, // ⇧ Shift - "DELETE_MAC": 0x75, // Forward Delete - "RETURN_MAC": 0x24, // Return - "ENTER_MAC": 0x4C, // Enter - "ESCAPE_MAC": 0x35, // Escape - "TAB_MAC": 0x30, // Tab - "SPACE_MAC": 0x31, // Space - "UP_ARROW_MAC": 0x7E, // Up Arrow - "DOWN_ARROW_MAC": 0x7D, // Down Arrow - "LEFT_ARROW_MAC": 0x7B, // Left Arrow - "RIGHT_ARROW_MAC": 0x7C, // Right Arrow - "F1_MAC": 0x7A, // F1 - "F2_MAC": 0x78, // F2 - "F3_MAC": 0x63, // F3 - "F4_MAC": 0x76, // F4 - "F5_MAC": 0x60, // F5 - "F6_MAC": 0x61, // F6 - "F7_MAC": 0x62, // F7 - "F8_MAC": 0x64, // F8 - "F9_MAC": 0x65, // F9 - "F10_MAC": 0x6D, // F10 - "F11_MAC": 0x67, // F11 - "F12_MAC": 0x6F, // F12 - "0_MAC": 0x52, // 0 - "1_MAC": 0x53, // 1 - "2_MAC": 0x54, // 2 - "3_MAC": 0x55, // 3 - "4_MAC": 0x56, // 4 - "5_MAC": 0x57, // 5 - "6_MAC": 0x58, // 6 - "7_MAC": 0x59, // 7 - "8_MAC": 0x5A, // 8 - "9_MAC": 0x5B, // 9 - "NUMPAD0_MAC": 0x4F, // Numpad 0 - "NUMPAD1_MAC": 0x50, // Numpad 1 - "NUMPAD2_MAC": 0x51, // Numpad 2 - "NUMPAD3_MAC": 0x52, // Numpad 3 - "NUMPAD4_MAC": 0x53, // Numpad 4 - "NUMPAD5_MAC": 0x54, // Numpad 5 - "NUMPAD6_MAC": 0x55, // Numpad 6 - "NUMPAD7_MAC": 0x56, // Numpad 7 - "NUMPAD8_MAC": 0x57, // Numpad 8 - "NUMPAD9_MAC": 0x58, // Numpad 9 - "NUMPAD_ADD_MAC": 0x45, // Numpad Add - "NUMPAD_SUBTRACT_MAC": 0x4A, // Numpad Subtract - "NUMPAD_MULTIPLY_MAC": 0x43, // Numpad Multiply - "NUMPAD_DIVIDE_MAC": 0x4B, // Numpad Divide - "NUMPAD_DECIMAL_MAC": 0x41, // Numpad Decimal + "ESCAPEMAC": 0x35, + "TABMAC": 0x30, + "RETURNMAC": 0x24, + "BACKSPACEMAC": 0x33, + "SPACEMAC": 0x31, + + "F1MAC": 0x7A, + "F2MAC": 0x78, + "F3MAC": 0x63, + "F4MAC": 0x76, + "F5MAC": 0x60, + "F6MAC": 0x61, + "F7MAC": 0x62, + "F8MAC": 0x64, + "F9MAC": 0x65, + "F10MAC": 0x6D, + "F11MAC": 0x67, + "F12MAC": 0x6F, + "F13MAC": 0x69, + "F14MAC": 0x6B, + "F15MAC": 0x71, + + "GRAVEACCENTMAC": 0x32, + "1MAC": 0x12, + "2MAC": 0x13, + "3MAC": 0x14, + "4MAC": 0x15, + "5MAC": 0x17, + "6MAC": 0x16, + "7MAC": 0x1A, + "8MAC": 0x1C, + "9MAC": 0x19, + "0MAC": 0x1D, + "MINUSMAC": 0x1B, + "EQUALSMAC": 0x18, + + "QMAC": 0x0C, + "WMAC": 0x0D, + "EMAC": 0x0E, + "RMAC": 0x0F, + "TMAC": 0x11, + "YMAC": 0x10, + "UMAC": 0x20, + "IMAC": 0x22, + "OMAC": 0x1F, + "PMAC": 0x23, + "OPENBRACKETMAC": 0x21, + "CLOSEBRACKETMAC": 0x1E, + "BACKSLASHMAC": 0x2A, + + "CAPSLOCKMAC": 0x39, + "AMAC": 0x00, + "SMAC": 0x01, + "DMAC": 0x02, + "FMAC": 0x03, + "GMAC": 0x05, + "HMAC": 0x04, + "JMAC": 0x26, + "KMAC": 0x28, + "LMAC": 0x25, + "SEMICOLONMAC": 0x29, + "SINGLEQUOTEMAC": 0x27, + + "SHIFTMAC": 0x38, + "ZMAC": 0x06, + "XMAC": 0x07, + "CMAC": 0x08, + "VMAC": 0x09, + "BMAC": 0x0B, + "NMAC": 0x2D, + "MMAC": 0x2E, + "COMMAMAC": 0x2B, + "PERIODMAC": 0x2F, + "FORWARDSLASHMAC": 0x2C, + + "CONTROL": 0x3B, + "RIGHTCONTROL": 0x3E, + "OPT": 0x3A, + "RIGHTOPT": 0x3D, + "CMD": 0x37, + "RIGHTCMD": 0x36, + "FUNCTION": 0x3F, + + "HOMEMAC": 0x73, + "ENDMAC": 0x77, + "PGUPMAC": 0x74, + "PGDNMAC": 0x79, + + "LEFTARRMAC": 0x7B, + "RIGHTARRMAC": 0x7C, + "DOWNARRMAC": 0x7D, + "UPARRMAC": 0x7E, + + "DELMAC": 0x75, + + "CONTEXTMAC": 0x72, + + "NUMPADCLEARMAC": 0x47, + "NUMPADDIVIDEMAC": 0x4B, + "NUMPADMULTIPLYMAC": 0x43, + "NUMPADSUBTRACTMAC": 0x4E, + "NUMPADADDMAC": 0x45, + + "NUMPAD0MAC": 0x4F, + "NUMPAD1MAC": 0x50, + "NUMPAD2MAC": 0x51, + "NUMPAD3MAC": 0x52, + "NUMPAD4MAC": 0x53, + "NUMPAD5MAC": 0x54, + "NUMPAD6MAC": 0x55, + "NUMPAD7MAC": 0x56, + "NUMPAD8MAC": 0x57, + "NUMPAD9MAC": 0x58, + + "NUMPADDECIMALMAC": 0x41, + "NUMPADEQUALMAC": 0x51, + "ENTERMAC": 0x4C, ] private var commands: [RemoteCommand] = [] diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index 541de781..029e2492 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -1226,6 +1226,68 @@ } } }, + "▴" : { + "comment" : "A symbol representing the \"up\" direction on a directional pad.", + "isCommentAutoGenerated" : true + }, + "▸" : { + "comment" : "A directional arrow pointing to the right.", + "isCommentAutoGenerated" : true + }, + "▾" : { + "comment" : "A symbol resembling a downward arrow.", + "isCommentAutoGenerated" : true + }, + "◂" : { + "comment" : "A directional arrow pointing to the left.", + "isCommentAutoGenerated" : true + }, + "A button with touchpad functionality will be created" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将创建一个带触摸板功能的按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將建立一個帶有觸控板功能的按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "將建立一個帶有觸控板功能的按鍵" + } + } + } + }, + "A touchpad with double-tap button triggering will be created" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将创建一个带双击按键触发功能的触摸板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將建立一個帶有雙擊按鍵觸發功能的觸控板" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "將建立一個帶有雙擊按鍵觸發功能的觸控板" + } + } + } + }, "About" : { "extractionState" : "manual", "localizations" : { @@ -1249,6 +1311,52 @@ } } }, + "Absolute touch drag" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "单点鼠标触控拖拽" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "單點滑鼠觸控拖曳" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "單點滑鼠觸控拖曳" + } + } + } + }, + "Activate toolbox menu during streaming" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在串流期间打开工具箱菜单" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在串流期間開啟工具箱選單" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "在串流期間開啟工具箱選單" + } + } + } + }, "Add" : { "extractionState" : "manual", "localizations" : { @@ -1479,6 +1587,29 @@ } } }, + "Arrow direction pad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "方向十字键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "方向十字鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "方向十字鍵" + } + } + } + }, "asyncFrameDequeueStackTip" : { "extractionState" : "manual", "localizations" : { @@ -1871,6 +2002,52 @@ } } }, + "Bring up softkeyboard" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开软键盘" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟軟鍵盤" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟軟鍵盤" + } + } + } + }, + "Brush shortcut" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "笔刷快捷键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "筆刷快速鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "筆刷快速鍵" + } + } + } + }, "Brush Shortcut" : { "extractionState" : "manual", "localizations" : { @@ -1894,6 +2071,29 @@ } } }, + "Button label" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按键标签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "按鍵標籤" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "按鍵標籤" + } + } + } + }, "Calibrating..." : { "extractionState" : "manual", "localizations" : { @@ -1940,6 +2140,29 @@ } } }, + "Cannot select two touchpad controls at the same time" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不能同时选择两个触摸板控件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "不能同時選擇兩個觸控板控制項" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "不能同時選擇兩個觸控板控制項" + } + } + } + }, "Check the command and parameter." : { "extractionState" : "manual", "localizations" : { @@ -1963,6 +2186,52 @@ } } }, + "Choose a gyro control style" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择陀螺仪控制样式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇陀螺儀控制樣式" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇陀螺儀控制樣式" + } + } + } + }, + "Circle" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "圆形" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圓形" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "圓形" + } + } + } + }, "Collapse" : { "extractionState" : "manual", "localizations" : { @@ -1986,6 +2255,98 @@ } } }, + "Collect, fold & unfold other widgets or subfolders" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收纳、折叠和展开其他控件或子控件夹" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "收納、摺疊和展開其他控制項或子控制項夾" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "收納、摺疊和展開其他控制項或子控制項夾" + } + } + } + }, + "Combination mode" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "组合模式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "組合模式" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "組合模式" + } + } + } + }, + "Combine this with a brush keyboard shortcut for PC software" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将此与 PC 软件的笔刷快捷键组合使用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將此與 PC 軟體的筆刷快速鍵搭配使用" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "將此與 PC 軟體的筆刷快速鍵搭配使用" + } + } + } + }, + "Combine this with a eraser keyboard shortcut for PC software" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将此与 PC 软件的橡皮擦快捷键组合使用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將此與 PC 軟體的橡皮擦快速鍵搭配使用" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "將此與 PC 軟體的橡皮擦快速鍵搭配使用" + } + } + } + }, "Command: " : { "extractionState" : "manual", "localizations" : { @@ -2355,6 +2716,29 @@ } } }, + "Create" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "建立" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "建立" + } + } + } + }, "Create New" : { "extractionState" : "manual", "localizations" : { @@ -2378,6 +2762,29 @@ } } }, + "Create widget" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建控件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "建立控制項" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "建立控制項" + } + } + } + }, "ctrlDownForPinchStackTip" : { "extractionState" : "manual", "localizations" : { @@ -2643,6 +3050,52 @@ } } }, + "Direction pad cannot be combined with gyro button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "方向键不能与陀螺仪按键组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "方向鍵不能與陀螺儀按鍵組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "方向鍵不能與陀螺儀按鍵組合" + } + } + } + }, + "Direction pad cannot be combined with their inner button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "方向键不能与其内侧按键组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "方向鍵不能與其內側按鍵組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "方向鍵不能與其內側按鍵組合" + } + } + } + }, "Disable hold-down button" : { "extractionState" : "manual", "localizations" : { @@ -2689,6 +3142,75 @@ } } }, + "Disable single finger touch for stream view" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "禁用串流视图的单指触控" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用串流畫面的單指觸控" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用串流畫面的單指觸控" + } + } + } + }, + "Disable single point touch" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "禁用单点触控" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用單點觸控" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用單點觸控" + } + } + } + }, + "Disable touch" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "禁用触控" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用觸控" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "停用觸控" + } + } + } + }, "Disconnect" : { "extractionState" : "manual", "localizations" : { @@ -2712,6 +3234,29 @@ } } }, + "Displacement-based right stick pad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "位移型右摇杆触控板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "位移型右搖桿觸控板" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "位移型右搖桿觸控板" + } + } + } + }, "Done" : { "extractionState" : "manual", "localizations" : { @@ -2787,6 +3332,52 @@ } } }, + "Down Arrow" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下箭头" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下箭頭" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "下箭頭" + } + } + } + }, + "Dpad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄十字键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把十字鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把十字鍵" + } + } + } + }, "Drag and drop buttons onto this trash can to remove them from the interface" : { "extractionState" : "manual", "localizations" : { @@ -2856,6 +3447,52 @@ } } }, + "DS4 touchpad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "DS4 触摸板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "DS4 觸控板" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "DS4 觸控板" + } + } + } + }, + "DS4 touchpad button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "DS4 触摸板按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "DS4 觸控板按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "DS4 觸控板按鍵" + } + } + } + }, "e.g. ctrl, lswheel, wasdpad..." : { "extractionState" : "manual", "localizations" : { @@ -2931,6 +3568,29 @@ } } }, + "Edit Layout" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑布局" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯版面" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯版面" + } + } + } + }, "Edit Selected Widget" : { "extractionState" : "manual", "localizations" : { @@ -3156,6 +3816,29 @@ } } }, + "Enter label(optional)" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入标签(可选)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "輸入標籤(可選)" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "輸入標籤(可選)" + } + } + } + }, "Enter the command & alias label" : { "extractionState" : "manual", "localizations" : { @@ -3312,6 +3995,29 @@ } } }, + "Eraser shortcut" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "橡皮擦快捷键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "橡皮擦快速鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "橡皮擦快速鍵" + } + } + } + }, "Eraser Shortcut" : { "extractionState" : "manual", "localizations" : { @@ -3404,6 +4110,29 @@ } } }, + "Expands setting menu during streaming" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在串流期间展开设置菜单" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在串流期間展開設定選單" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "在串流期間展開設定選單" + } + } + } + }, "Experimental" : { "extractionState" : "manual", "localizations" : { @@ -3704,6 +4433,52 @@ } } }, + "Folder" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件夹(控件夹)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料夾(控制項夾)" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料夾(控制項夾)" + } + } + } + }, + "Force pencil entering hovering on host" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "强制让 Pencil 在主机端进入悬停状态" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "強制讓 Pencil 在主機端進入懸浮狀態" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "強制讓 Pencil 在主機端進入懸浮狀態" + } + } + } + }, "framePacingStackTip" : { "extractionState" : "manual", "localizations" : { @@ -3802,6 +4577,305 @@ } } }, + "Functional button widgets cannot be combined with other functional buttons" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能按键控件不能与其他功能按键组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能按鍵控制項不能與其他功能按鍵組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能按鍵控制項不能與其他功能按鍵組合" + } + } + } + }, + "Functional button widgets cannot be combined with touchpad controls" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能按键控件不能与触摸板控件组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能按鍵控制項不能與觸控板控制項組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能按鍵控制項不能與觸控板控制項組合" + } + } + } + }, + "Functional Buttons" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "功能按鍵" + } + } + } + }, + "Gamepad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把" + } + } + } + }, + "Gamepad A" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄 A" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把 A" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把 A" + } + } + } + }, + "Gamepad B" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄 B" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把 B" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把 B" + } + } + } + }, + "Gamepad Down" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄下" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把下" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把下" + } + } + } + }, + "Gamepad Home" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄主页键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把首頁鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把首頁鍵" + } + } + } + }, + "Gamepad Left" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄左" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把左" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把左" + } + } + } + }, + "Gamepad Right" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄右" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把右" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把右" + } + } + } + }, + "Gamepad Up" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄上" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把上" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把上" + } + } + } + }, + "Gamepad X" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄 X" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把 X" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把 X" + } + } + } + }, + "Gamepad Y" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手柄 Y" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把 Y" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "手把 Y" + } + } + } + }, "Gestures" : { "extractionState" : "manual", "localizations" : { @@ -3917,6 +4991,98 @@ } } }, + "Gyro off while activated" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "激活时关闭陀螺仪" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用時關閉陀螺儀" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用時關閉陀螺儀" + } + } + } + }, + "Gyro on while activated" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "激活时开启陀螺仪" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用時開啟陀螺儀" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "啟用時開啟陀螺儀" + } + } + } + }, + "Gyro switch button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "陀螺仪切换按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "陀螺儀切換按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "陀螺儀切換按鍵" + } + } + } + }, + "Gyro widgets cannot be combined with direction pad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "陀螺仪控件不能与方向键组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "陀螺儀控制項不能與方向鍵組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "陀螺儀控制項不能與方向鍵組合" + } + } + } + }, "gyroModeStackTip" : { "extractionState" : "manual", "localizations" : { @@ -4286,6 +5452,29 @@ } } }, + "Keyboard / Mouse" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "键盘 / 鼠标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "鍵盤 / 滑鼠" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "鍵盤 / 滑鼠" + } + } + } + }, "Label alpha: %.2f" : { "extractionState" : "manual", "localizations" : { @@ -4424,6 +5613,29 @@ } } }, + "LB/L1" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LB/L1" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "LB/L1" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "LB/L1" + } + } + } + }, "Learn More" : { "extractionState" : "manual", "localizations" : { @@ -4516,6 +5728,52 @@ } } }, + "Left Arrow" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左箭头" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "左箭頭" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "左箭頭" + } + } + } + }, + "Left Button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "左鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "左鍵" + } + } + } + }, "Left stick" : { "extractionState" : "manual", "localizations" : { @@ -4539,6 +5797,75 @@ } } }, + "Left stick pad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左摇杆触控板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿觸控板" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿觸控板" + } + } + } + }, + "Left stick wheel" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左摇杆方向轮" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿方向輪" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "左搖桿方向輪" + } + } + } + }, + "Left trigger pad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "左扳机触控板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "左扳機觸控板" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "左扳機觸控板" + } + } + } + }, "Load" : { "extractionState" : "manual", "localizations" : { @@ -4695,6 +6022,98 @@ } } }, + "LS wheel must be placed alone" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LS 滚轮必须单独放置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "LS 滾輪必須單獨放置" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "LS 滾輪必須單獨放置" + } + } + } + }, + "LS/L3 button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LS/L3 按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "LS/L3 按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "LS/L3 按鍵" + } + } + } + }, + "LsWheel cannot be combined with other widgets" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LsWheel 不能与其他控件组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "LsWheel 不能與其他控制項組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "LsWheel 不能與其他控制項組合" + } + } + } + }, + "LT/L2 button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LT/L2 按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "LT/L2 按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "LT/L2 按鍵" + } + } + } + }, "Manual_Add_Host_Fail" : { "extractionState" : "manual", "localizations" : { @@ -4857,6 +6276,29 @@ } } }, + "Middle Button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "中键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "中鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "中鍵" + } + } + } + }, "Minimum offset: %.0f" : { "extractionState" : "manual", "localizations" : { @@ -5024,6 +6466,52 @@ } } }, + "mouse widgets" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "鼠标控件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠控制項" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠控制項" + } + } + } + }, + "Mouse Widgets" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "鼠标控件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠控制項" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠控制項" + } + } + } + }, "mouseModeSwitchButtonTip" : { "extractionState" : "manual", "localizations" : { @@ -5053,6 +6541,29 @@ } } }, + "MousePad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "鼠标触控板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠觸控板" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠觸控板" + } + } + } + }, "Move the stick you wish to use for mouse control. Another stick will be used for vertical & horizontal scroll." : { "extractionState" : "manual", "localizations" : { @@ -5393,6 +6904,52 @@ } } }, + "numpad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "小键盘" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "數字鍵盤" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "數字鍵盤" + } + } + } + }, + "Numpad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "小键盘" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "數字鍵盤" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "數字鍵盤" + } + } + } + }, "NVIDIA GameStream End-of-Service" : { "extractionState" : "manual", "localizations" : { @@ -5681,6 +7238,29 @@ } } }, + "Open on-screen widget layout tool" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开屏显控件布局工具" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟螢幕控制項版面工具" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟螢幕控制項版面工具" + } + } + } + }, "Open widget tool in streaming by:\nSliding from %@ screen edge to open cmd tool.\nOr tap %d fingers on stream view, number of fingers required:" : { "extractionState" : "manual", "localizations" : { @@ -5704,6 +7284,29 @@ } } }, + "Opens pencil pressure curve tool" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 Pencil 压感曲线工具" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟 Pencil 壓感曲線工具" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟 Pencil 壓感曲線工具" + } + } + } + }, "optimizeGamesStackTip" : { "extractionState" : "manual", "localizations" : { @@ -5986,6 +7589,29 @@ } } }, + "Pencil hover" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pencil 悬停" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pencil 懸浮" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pencil 懸浮" + } + } + } + }, "Pencil Pro Pack" : { "extractionState" : "manual", "localizations" : { @@ -6255,6 +7881,52 @@ } } }, + "Pick a game/on-screen-widget profile" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择一个游戏/屏显控件配置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇一個遊戲/螢幕控制項配置" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇一個遊戲/螢幕控制項配置" + } + } + } + }, + "Pick profile" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选取布局" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選取版面" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "選取版面" + } + } + } + }, "pipStackTip" : { "extractionState" : "manual", "localizations" : { @@ -6607,6 +8279,29 @@ } } }, + "Pressure curve" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "压感曲线" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "壓感曲線" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "壓感曲線" + } + } + } + }, "Pressure curve saved" : { "extractionState" : "manual", "localizations" : { @@ -6820,6 +8515,29 @@ } } }, + "Profiles" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "布局清单" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "版面清單" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "版面清單" + } + } + } + }, "Purchase Now" : { "extractionState" : "manual", "localizations" : { @@ -7004,6 +8722,29 @@ } } }, + "RB/R1" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RB/R1" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "RB/R1" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "RB/R1" + } + } + } + }, "Read Widget Instruction" : { "extractionState" : "manual", "localizations" : { @@ -7258,6 +8999,29 @@ } } }, + "Replace mouse button action in single point touch mode with another button temporarily" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在单点触控模式下,临时将鼠标按键操作替换为另一个按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在單點觸控模式下,暫時將滑鼠按鍵操作替換成另一個按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "在單點觸控模式下,暫時將滑鼠按鍵操作替換成另一個按鍵" + } + } + } + }, "Reset" : { "extractionState" : "manual", "localizations" : { @@ -7471,6 +9235,52 @@ } } }, + "Right Arrow" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "右箭头" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "右箭頭" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "右箭頭" + } + } + } + }, + "Right Button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "右键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "右鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "右鍵" + } + } + } + }, "Right stick" : { "extractionState" : "manual", "localizations" : { @@ -7494,6 +9304,29 @@ } } }, + "Right trigger pad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "右扳机触控板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "右扳機觸控板" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "右扳機觸控板" + } + } + } + }, "Roll factor: %.2f" : { "extractionState" : "manual", "localizations" : { @@ -7517,6 +9350,52 @@ } } }, + "RS/R3 button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RS/R3 按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "RS/R3 按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "RS/R3 按鍵" + } + } + } + }, + "RT/R2 button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RT/R2 按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "RT/R2 按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "RT/R2 按鍵" + } + } + } + }, "Save" : { "extractionState" : "manual", "localizations" : { @@ -7650,6 +9529,121 @@ } } }, + "Select" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇" + } + } + } + }, + "Select a game/on-screen-widget profile" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择一个游戏/屏显控件配置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇一個遊戲/螢幕控制項配置" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇一個遊戲/螢幕控制項配置" + } + } + } + }, + "Select at least one command before creating a widget" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建控件前至少选择一个命令" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "建立控制項前至少選擇一個命令" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "建立控制項前至少選擇一個命令" + } + } + } + }, + "Select Control" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择控件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇控制項" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇控制項" + } + } + } + }, + "Select Gyro Button" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择陀螺仪按键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇陀螺儀按鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇陀螺儀按鍵" + } + } + } + }, "Select Host" : { "extractionState" : "manual", "localizations" : { @@ -7696,6 +9690,29 @@ } } }, + "Selected widgets are queued here in tap order." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已选控件会按点击顺序排列在此" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已選控制項會依點擊順序排列在此" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "已選控制項會依點擊順序排列在此" + } + } + } + }, "sendDummyEventStackTip" : { "extractionState" : "manual", "localizations" : { @@ -7840,6 +9857,29 @@ } } }, + "Shape" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "形状" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "形狀" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "形狀" + } + } + } + }, "Shape: " : { "extractionState" : "manual", "localizations" : { @@ -7863,6 +9903,29 @@ } } }, + "Shortcut combo" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "快捷键组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速鍵組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "快速鍵組合" + } + } + } + }, "Show App" : { "extractionState" : "manual", "localizations" : { @@ -7961,6 +10024,29 @@ } } }, + "Skill combo" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "技能组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "技能組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "技能組合" + } + } + } + }, "Slide Distance for in-Stream Menu: %.2f * screen-width" : { "extractionState" : "manual", "localizations" : { @@ -8059,6 +10145,29 @@ } } }, + "SoftKeyboard" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "软键盘" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "軟鍵盤" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "軟鍵盤" + } + } + } + }, "softKeyboardGestureStackDoc" : { "extractionState" : "manual", "localizations" : { @@ -8146,6 +10255,29 @@ } } }, + "Square" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "方形" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "方形" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "方形" + } + } + } + }, "Squeeze Shortcut" : { "extractionState" : "manual", "localizations" : { @@ -8440,6 +10572,29 @@ } } }, + "Tap any control to add it" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击任意控件以添加" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "點擊任一控制項即可加入" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "點擊任一控制項即可加入" + } + } + } + }, "Tap Exclusion Area Size for Custom OSC: %d%%" : { "extractionState" : "manual", "localizations" : { @@ -8486,6 +10641,29 @@ } } }, + "Temporarily disable touch input for stream view" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "临时禁用串流视图的触控输入" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫時停用串流畫面的觸控輸入" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫時停用串流畫面的觸控輸入" + } + } + } + }, "Test Network" : { "extractionState" : "manual", "localizations" : { @@ -8555,6 +10733,52 @@ } } }, + "This functional button cannot be combined with gamepad buttons" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此功能按键不能与手柄按键组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此功能按鍵不能與手把按鍵組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "此功能按鍵不能與手把按鍵組合" + } + } + } + }, + "This functional button cannot be combined with keyboard or mouse buttons" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此功能按键不能与键盘或鼠标按键组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此功能按鍵不能與鍵盤或滑鼠按鍵組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "此功能按鍵不能與鍵盤或滑鼠按鍵組合" + } + } + } + }, "This is an experimental feature that may cause stuttering or freezing in the stream view." : { "extractionState" : "manual", "localizations" : { @@ -8889,6 +11113,75 @@ } } }, + "Trackball" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "轨迹球" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "軌跡球" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "軌跡球" + } + } + } + }, + "Trigger button cannot be combined with trigger pad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扳机按键不能与扳机触控板组合" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "扳機按鍵不能與扳機觸控板組合" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "扳機按鍵不能與扳機觸控板組合" + } + } + } + }, + "Trigger interval" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "触发间隔" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "觸發間隔" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "觸發間隔" + } + } + } + }, "Undo" : { "extractionState" : "manual", "localizations" : { @@ -8987,6 +11280,29 @@ } } }, + "Up Arrow" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上箭头" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上箭頭" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "上箭頭" + } + } + } + }, "useBuiltinMicStackTip" : { "extractionState" : "manual", "localizations" : { @@ -9016,6 +11332,29 @@ } } }, + "Velocity-based right stick pad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "矢量型右摇杆触控板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "矢量型右搖桿觸控板" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "矢量型右搖桿觸控板" + } + } + } + }, "Version %@" : { }, @@ -9226,6 +11565,29 @@ } } }, + "WASD direction pad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "WASD 十字键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "WASD 十字鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "WASD 十字鍵" + } + } + } + }, "waterMarkText" : { "extractionState" : "manual", "localizations" : { @@ -9255,6 +11617,213 @@ } } }, + "Wheel\nDown" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "滚轮\n下" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滾輪\n下" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滾輪\n下" + } + } + } + }, + "Wheel\nUp" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "滚轮\n上" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滾輪\n上" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滾輪\n上" + } + } + } + }, + "WheelPad" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "滚轮触控板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滾輪觸控板" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滾輪觸控板" + } + } + } + }, + "Widget config generated" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控件配置已生成" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項配置已產生" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項配置已產生" + } + } + } + }, + "Widget Picker" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控件选择器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項選擇器" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項選擇器" + } + } + } + }, + "Widget Pool" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控件池" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項池" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項池" + } + } + } + }, + "Widget pool is full" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "控件池已满" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項池已滿" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "控制項池已滿" + } + } + } + }, + "X1" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "X1" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "X1" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "X1" + } + } + } + }, + "X2" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "X2" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "X2" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "X2" + } + } + } + }, "Yaw factor: %.2f" : { "extractionState" : "manual", "localizations" : { @@ -9301,6 +11870,29 @@ } } }, + "You can continue adding controls to this combination" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你可以继续向此组合添加控件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你可以繼續向此組合加入控制項" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "你可以繼續向此組合加入控制項" + } + } + } + }, "Your network connection isn't performing well. Reduce your video bitrate setting or try a faster connection." : { "extractionState" : "manual", "localizations" : { @@ -9386,5 +11978,5 @@ } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.h b/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.h index fb4212fc..ac18610f 100644 --- a/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.h +++ b/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.h @@ -21,7 +21,7 @@ NS_ASSUME_NONNULL_BEGIN /** This view controller provides the user interface which allows the user to position on screen controller buttons anywhere they'd like on the screen. It also provides the user with the abilities to undo a change, save the on screen controller layout for later retrieval, and load previously saved controller layouts */ -@interface LayoutOnScreenControlsViewController : UIViewController +@interface LayoutOnScreenControlsViewController : UIViewController - (void)profileRefresh; - (void)reloadOnScreenWidgetViews; - (void)presentProfilesTableViewWithPickProfile:(bool)pickProfile; diff --git a/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.m b/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.m index ae948673..6751c864 100644 --- a/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.m +++ b/VoidLink/ViewControllers/CustomOSCViewControl/LayoutOnScreenControlsViewController.m @@ -19,7 +19,7 @@ // #import "ThemeManager.h" #import "DataManager.h" -@interface LayoutOnScreenControlsViewController () +@interface LayoutOnScreenControlsViewController () typedef NS_ENUM(NSUInteger, AlphaSliderMode) { widgetAlpha, @@ -660,6 +660,18 @@ typedef NS_ENUM(NSUInteger, DecelerationRateSliderMode) { - (IBAction) addTapped:(id)sender{ GenericUtils.autoPopSoftKeyboard = false; + + if (@available(iOS 13.0, *)) { + WidgetPickerViewController *pickerViewController = [[WidgetPickerViewController alloc] init]; + pickerViewController.delegate = self; + pickerViewController.tabIdentifiers = @[@"gamepad", @"keyboard", @"functional"]; + pickerViewController.initialTabIdentifier = @"gamepad"; + + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:pickerViewController]; + navigationController.modalPresentationStyle = UIModalPresentationOverFullScreen; + [self presentViewController:navigationController animated:YES completion:nil]; + return; + } NSMutableDictionary* widgetInitParams = [NSMutableDictionary dictionary]; @@ -745,6 +757,24 @@ typedef NS_ENUM(NSUInteger, DecelerationRateSliderMode) { [self presentViewController:alertController animated:YES completion:nil]; } +- (void)widgetPickerViewController:(WidgetPickerViewController *)controller didCreateWidget:(NSDictionary *)payload { + NSMutableDictionary *widgetInitParams = [payload mutableCopy]; + NSString *pickerAction = [widgetInitParams[@"pickerAction"] lowercaseString]; + [widgetInitParams removeObjectForKey:@"pickerAction"]; + + if ([pickerAction isEqualToString:@"modify"] && self->selectedWidgetView != nil) { + [self updateWidget:self->selectedWidgetView byParams:widgetInitParams createNew:false]; + return; + } + + if ([pickerAction isEqualToString:@"create"] && controller.isEditMode && self->selectedWidgetView != nil) { + [self updateWidget:self->selectedWidgetView byParams:widgetInitParams createNew:true]; + return; + } + + [self createWidgetFromParams:widgetInitParams]; +} + - (IBAction) editTapped:(id)sender{ GenericUtils.autoPopSoftKeyboard = false; @@ -767,6 +797,22 @@ typedef NS_ENUM(NSUInteger, DecelerationRateSliderMode) { completion:^{}]; return; }; + + if (@available(iOS 13.0, *)) { + WidgetPickerViewController *pickerViewController = [[WidgetPickerViewController alloc] init]; + pickerViewController.delegate = self; + pickerViewController.tabIdentifiers = @[@"gamepad", @"keyboard", @"functional"]; + pickerViewController.initialTabIdentifier = @"gamepad"; + pickerViewController.isEditMode = true; + pickerViewController.initialCmdString = self->selectedWidgetView.cmdString; + pickerViewController.initialButtonLabel = self->selectedWidgetView.widgetLabel; + pickerViewController.initialShape = self->selectedWidgetView.shape; + + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:pickerViewController]; + navigationController.modalPresentationStyle = UIModalPresentationOverFullScreen; + [self presentViewController:navigationController animated:YES completion:nil]; + return; + } [alertController addTextFieldWithConfigurationHandler:^(UITextField *textField) { UILabel *label = [[UILabel alloc] init]; diff --git a/VoidLinkSwiftUI/AbstractGamepadView.swift b/VoidLinkSwiftUI/AbstractGamepadView.swift new file mode 100644 index 00000000..ae3bb184 --- /dev/null +++ b/VoidLinkSwiftUI/AbstractGamepadView.swift @@ -0,0 +1,2067 @@ +// +// AbstractGamepadView.swift +// VoidLink +// +// Created by True砖家 on 2026/3/25. +// Copyright © 2026 True砖家 @ Bilibili. All rights reserved. +// + +import SwiftUI +import Combine + +@available(iOS 13.0, *) +struct DPadHighlight: OptionSet { + let rawValue: Int + + static let up = DPadHighlight(rawValue: 1 << 0) + static let down = DPadHighlight(rawValue: 1 << 1) + static let left = DPadHighlight(rawValue: 1 << 2) + static let right = DPadHighlight(rawValue: 1 << 3) + + static let all: DPadHighlight = [.up, .down, .left, .right] + static let none: DPadHighlight = [] +} + +@available(iOS 13.0, *) +enum StickHighlightMode: Equatable { + case none + case outerOnly + case thumbOnly + case both +} + +@available(iOS 13.0, *) +enum TriggerHighlightMode: Equatable { + case none + case button + case pad +} + +@available(iOS 13.0, *) +enum DS4TouchHighlightMode: Equatable { + case none + case pad + case button +} + +@available(iOS 13.0, *) +enum GamepadType: Equatable { + case xbox + case ps +} + +@available(iOS 13.0, *) +enum GamepadToggleTarget: Hashable { + case a, b, x, y + case select, start, home + case leftStick, rightStick + case leftShoulder, rightShoulder + case leftTrigger, rightTrigger + case ds4Touchpad + + var widgets: [GamepadWidget] { + switch self { + case .a: return [.a] + case .b: return [.b] + case .x: return [.x] + case .y: return [.y] + case .select: return [.select] + case .start: return [.start] + case .home: return [.home] + case .leftStick: return [.ls, .lswheel, .lsPad] + case .rightStick: return [.rs, .rsPad, .rsvPad] + case .leftShoulder: return [.leftShoulder] + case .rightShoulder: return [.rightShoulder] + case .leftTrigger: return [.leftTrigger, .ltPad] + case .rightTrigger: return [.rightTrigger, .rtPad] + case .ds4Touchpad: return [.ds4Touchpad, .ds4TouchpadButton] + } + } +} + +@available(iOS 13.0, *) +enum GamepadWidget: Hashable { + case a, b, x, y + case select, start, home + case dPad, up, down, left, right + case ls, lsPad, lswheel, rs, rsPad, rsvPad + case leftShoulder, rightShoulder + case leftTrigger, ltPad, rightTrigger, rtPad + case ds4Touchpad + case ds4TouchpadButton + + var cmd: String { + switch self { + case .a: return "OSCA" + case .b: return "OSCB" + case .x: return "OSCX" + case .y: return "OSCY" + case .select: return "OSCSELECT" + case .start: return "OSCSTART" + case .home: return "HOME" + case .dPad: return "DPAD" + case .up: return "OSCUP" + case .down: return "OSCDOWN" + case .left: return "OSCLEFT" + case .right: return "OSCRIGHT" + case .ls: return "LS" + case .lswheel: return "LSWHEEL" + case .lsPad: return "LSPAD" + case .rs: return "RS" + case .rsPad: return "RSPAD" + case .rsvPad: return "RSVPAD" + case .leftShoulder: return "LB" + case .rightShoulder: return "RB" + case .leftTrigger: return "LT" + case .ltPad: return "LTPAD" + case .rightTrigger: return "RT" + case .rtPad: return "RTPAD" + case .ds4Touchpad: return "DS4TOUCH" + case .ds4TouchpadButton: return "DS4TCHBTN" + } + } + + var description: String { + switch self { + case .a: return SwiftLocalizationHelper.localizedString(forKey: "Gamepad A") + case .b: return SwiftLocalizationHelper.localizedString(forKey: "Gamepad B") + case .x: return SwiftLocalizationHelper.localizedString(forKey: "Gamepad X") + case .y: return SwiftLocalizationHelper.localizedString(forKey: "Gamepad Y") + case .select: return SwiftLocalizationHelper.localizedString(forKey: "Select") + case .start: return SwiftLocalizationHelper.localizedString(forKey: "Start") + case .home: return SwiftLocalizationHelper.localizedString(forKey: "Gamepad Home") + case .dPad: return SwiftLocalizationHelper.localizedString(forKey: "Dpad") + case .up: return SwiftLocalizationHelper.localizedString(forKey: "Gamepad Up") + case .down: return SwiftLocalizationHelper.localizedString(forKey: "Gamepad Down") + case .left: return SwiftLocalizationHelper.localizedString(forKey: "Gamepad Left") + case .right: return SwiftLocalizationHelper.localizedString(forKey: "Gamepad Right") + case .ls: return SwiftLocalizationHelper.localizedString(forKey: "LS/L3 button") + case .lswheel: return SwiftLocalizationHelper.localizedString(forKey: "Left stick wheel") + case .lsPad: return SwiftLocalizationHelper.localizedString(forKey: "Left stick pad") + case .rs: return SwiftLocalizationHelper.localizedString(forKey: "RS/R3 button") + case .rsPad: return SwiftLocalizationHelper.localizedString(forKey: "Displacement-based right stick pad") + case .rsvPad: return SwiftLocalizationHelper.localizedString(forKey: "Velocity-based right stick pad") + case .leftShoulder: return SwiftLocalizationHelper.localizedString(forKey: "LB/L1") + case .rightShoulder: return SwiftLocalizationHelper.localizedString(forKey: "RB/R1") + case .leftTrigger: return SwiftLocalizationHelper.localizedString(forKey: "LT/L2 button") + case .ltPad: return SwiftLocalizationHelper.localizedString(forKey: "Left trigger pad") + case .rightTrigger: return SwiftLocalizationHelper.localizedString(forKey: "RT/R2 button") + case .rtPad: return SwiftLocalizationHelper.localizedString(forKey: "Right trigger pad") + case .ds4Touchpad: return SwiftLocalizationHelper.localizedString(forKey: "DS4 touchpad") + case .ds4TouchpadButton: return SwiftLocalizationHelper.localizedString(forKey: "DS4 touchpad button") + } + } +} + + +@available(iOS 13.0, *) +struct AbstractGamepadView: View { + let gamepadType: GamepadType + var canSelectCommand: ((String) -> Bool)? = nil + var isCommandSelected: ((String) -> Bool)? = nil + var onCommandSelected: ((String) -> Void)? = nil + var onCommandDeselected: ((String) -> Void)? = nil + var resetToken: Int = 0 + var selectionSyncToken: Int = 0 + var externalDeselectionCommand: String? = nil + var externalDeselectionToken: Int = 0 + + init( + gamepadType: GamepadType = .xbox, + canSelectCommand: ((String) -> Bool)? = nil, + isCommandSelected: ((String) -> Bool)? = nil, + onCommandSelected: ((String) -> Void)? = nil, + onCommandDeselected: ((String) -> Void)? = nil, + resetToken: Int = 0, + selectionSyncToken: Int = 0, + externalDeselectionCommand: String? = nil, + externalDeselectionToken: Int = 0 + ) { + self.gamepadType = gamepadType + self.canSelectCommand = canSelectCommand + self.isCommandSelected = isCommandSelected + self.onCommandSelected = onCommandSelected + self.onCommandDeselected = onCommandDeselected + self.resetToken = resetToken + self.selectionSyncToken = selectionSyncToken + self.externalDeselectionCommand = externalDeselectionCommand + self.externalDeselectionToken = externalDeselectionToken + } + + @SwiftUI.State private var activeButtons: Set = [] + @SwiftUI.State private var dpadHighlight: DPadHighlight = .none + @SwiftUI.State private var dpadWholePressed = false + @SwiftUI.State private var leftStickHighlight: StickHighlightMode = .none + @SwiftUI.State private var rightStickHighlight: StickHighlightMode = .none + + @SwiftUI.State private var pendingWidgetOptions: [GamepadWidget] = [] + @SwiftUI.State private var showWidgetPicker = false + @SwiftUI.State private var pickerSourceIsDPad = false + + @SwiftUI.State private var leftTriggerHighlight: TriggerHighlightMode = .none + @SwiftUI.State private var rightTriggerHighlight: TriggerHighlightMode = .none + @SwiftUI.State private var ds4TouchHighlight: DS4TouchHighlightMode = .none + @SwiftUI.State private var lastAppliedResetToken: Int = 0 + @SwiftUI.State private var lastAppliedSelectionSyncToken: Int = 0 + @SwiftUI.State private var lastAppliedExternalDeselectionToken: Int = 0 + + static var tappedWidgets: [GamepadWidget] = [] + static var selectedCmd: String = "" + private struct Layout { + + // MARK: - Body + + static let aspectRatio: CGFloat = 1.82 + static let bodyCornerRadiusRatio: CGFloat = 0.3 + static let bodyShadowYOffsetRatio: CGFloat = 0.035 + static let innerStrokeInsetRatio: CGFloat = 0.018 + static let radialHighlightEndRadiusRatio: CGFloat = 0.48 + static let innerContentScale: CGFloat = 1.13 + static let ds4TouchpadWidthRatio: CGFloat = 0.2 + static let ds4TouchpadHeightRatio: CGFloat = 0.135 + static let ds4TouchpadCornerRatio: CGFloat = 0.30 + + // MARK: - Top decoration + + static let topTriggerStubHorizontalPaddingRatio: CGFloat = 0.11 + static let topTriggerStubCenterYRatio: CGFloat = -0.236 + static let topTriggerStubCenterXRatio: CGFloat = 0.008 + + static let topTriggerStubWidthRatio: CGFloat = 0.1062 + static let topTriggerStubHeightRatio: CGFloat = 0.0670 + static let topTriggerStubSpacingRatio: CGFloat = 0.4336 + + static let shoulderBarWidthRatio: CGFloat = 0.24 + static let shoulderBarHeightRatio: CGFloat = 0.04 + static let shoulderBarInsetFromSideRatio: CGFloat = 0.06 + static let shoulderBarCenterYRatio: CGFloat = -0.49 + + // MARK: - Component sizes + + static let leftStickSizeRatio: CGFloat = 0.17 + static let rightStickSizeRatio: CGFloat = 0.17 + static let dpadSizeRatio: CGFloat = 0.17 + static let abxySizeRatio: CGFloat = 0.19 + + /// 顶部 +/- 和右下角 Home 共用直径 + static let smallTopButtonDiameterRatio: CGFloat = 0.053 + + // MARK: - Constraint groups + + /// DPad / 右摇杆 共用水平轴 + static let lowerControlCenterYRatio: CGFloat = 0.16 + + /// DPad / 右摇杆 关于中线左右对称 + static let lowerControlHalfSpacingRatio: CGFloat = 0.16 + + /// 顶部 +/- 共用水平轴 + static let topButtonCenterYRatio: CGFloat = -0.24 + + // MARK: - Left stick / ABXY / Home + + static let leftStickCenterXRatio: CGFloat = -0.28 + static let leftStickCenterYRatio: CGFloat = -0.07 + + /// Home 的垂直轴,ABXY 与它垂直对齐 + static let homeCenterXRatio: CGFloat = 0.3 + + /// Home 的垂直偏移,允许手调 + static let homeCenterYRatio: CGFloat = 0.267 + } + + private func toggle(_ target: GamepadToggleTarget) { + if activeButtons.contains(target) { + activeButtons.remove(target) + for widget in target.widgets { + onCommandDeselected?(widget.cmd) + } + } else { + activeButtons.insert(target) + let didAccept = handleTappedWidgets(target.widgets, sourceIsDPad: false) + if !didAccept { + activeButtons.remove(target) + } + } + } + + @discardableResult + private func handleTappedWidgets(_ widgets: [GamepadWidget], sourceIsDPad: Bool) -> Bool { + AbstractGamepadView.tappedWidgets = widgets + pickerSourceIsDPad = sourceIsDPad + + print(AbstractGamepadView.tappedWidgets) + + guard !widgets.isEmpty else { return false } + + if widgets.count == 1, let widget = widgets.first { + return applySelectedWidget(widget, sourceIsDPad: sourceIsDPad) + } else { + pendingWidgetOptions = widgets + showWidgetPicker = true + return true + } + } + + private func nextStickHighlightMode( + current: StickHighlightMode, + selected widget: GamepadWidget, + outerWidget: GamepadWidget, + thumbWidgets: [GamepadWidget] + ) -> StickHighlightMode { + + if widget == outerWidget { + switch current { + case .thumbOnly, .both: + return .both + case .none, .outerOnly: + return .outerOnly + } + } + + if thumbWidgets.contains(widget) { + switch current { + case .outerOnly: + // 只有“先按键,再选 pad/thumb”才允许两块都亮 + return .both + case .none, .thumbOnly, .both: + // 先选过 pad/thumb,或者已经是 both,再选 pad/thumb,都不升级为 both + return .thumbOnly + } + } + + return current + } + + private func applyStickSelection(_ widget: GamepadWidget) { + switch widget { + case .ls, .lsPad, .lswheel: + leftStickHighlight = nextStickHighlightMode( + current: leftStickHighlight, + selected: widget, + outerWidget: .ls, + thumbWidgets: [.lsPad, .lswheel] + ) + + case .rs, .rsPad, .rsvPad: + rightStickHighlight = nextStickHighlightMode( + current: rightStickHighlight, + selected: widget, + outerWidget: .rs, + thumbWidgets: [.rsPad, .rsvPad] + ) + if widget == .rs, + isCommandSelected?(GamepadWidget.rsPad.cmd) == true || isCommandSelected?(GamepadWidget.rsvPad.cmd) == true { + rightStickHighlight = .both + } + + default: + break + } + } + + @discardableResult + private func applySelectedWidget(_ widget: GamepadWidget, sourceIsDPad: Bool) -> Bool { + if canSelectCommand?(widget.cmd) == false { + return false + } + AbstractGamepadView.selectedCmd = widget.cmd + onCommandSelected?(widget.cmd) + + if sourceIsDPad { + applyDPadSelection(widget) + } + + applyStickSelection(widget) + applyTriggerSelection(widget) + applyDS4TouchSelection(widget) + + print("selectedCmd =", AbstractGamepadView.selectedCmd) + return true + } + + private func applyTriggerSelection(_ widget: GamepadWidget) { + switch widget { + case .leftTrigger: + leftTriggerHighlight = .button + case .ltPad: + leftTriggerHighlight = .pad + case .rightTrigger: + rightTriggerHighlight = .button + case .rtPad: + rightTriggerHighlight = .pad + default: + break + } + } + + private func selectWidget(_ widget: GamepadWidget) { + let didSelect = applySelectedWidget(widget, sourceIsDPad: pickerSourceIsDPad) + if !didSelect { + AbstractGamepadView.tappedWidgets = [] + } + pendingWidgetOptions.removeAll() + showWidgetPicker = false + } + + private func applyDS4TouchSelection(_ widget: GamepadWidget) { + switch widget { + case .ds4Touchpad: + ds4TouchHighlight = .pad + case .ds4TouchpadButton: + ds4TouchHighlight = .button + default: + break + } + } + + private func resetCurrentWidgetPickerSelection() { + for widget in pendingWidgetOptions { + onCommandDeselected?(widget.cmd) + } + clearHighlights(for: pendingWidgetOptions) + AbstractGamepadView.tappedWidgets = [] + AbstractGamepadView.selectedCmd = "" + showWidgetPicker = false + } + + private func clearHighlights(for widgets: [GamepadWidget]) { + if widgets.contains(.dPad) || widgets.contains(.up) || widgets.contains(.down) || widgets.contains(.left) || widgets.contains(.right) { + dpadHighlight = .none + } + + if widgets.contains(.ls) || widgets.contains(.lsPad) || widgets.contains(.lswheel) { + leftStickHighlight = .none + } + + if widgets.contains(.rs) || widgets.contains(.rsPad) || widgets.contains(.rsvPad) { + rightStickHighlight = .none + } + + if widgets.contains(.leftTrigger) || widgets.contains(.ltPad) { + leftTriggerHighlight = .none + } + + if widgets.contains(.rightTrigger) || widgets.contains(.rtPad) { + rightTriggerHighlight = .none + } + + if widgets.contains(.ds4Touchpad) || widgets.contains(.ds4TouchpadButton) { + ds4TouchHighlight = .none + } + } + + private func toggleDPadSelection(for region: DPadHighlight) { + _ = region + + if dpadWholePressed { + dpadHighlight = .none + dpadWholePressed = false + onCommandDeselected?(GamepadWidget.dPad.cmd) + return + } + + handleTappedWidgets([.dPad, .up, .down, .left, .right], sourceIsDPad: true) + } + + private func applyDPadSelection(_ widget: GamepadWidget) { + switch widget { + case .dPad: + dpadHighlight = .none + dpadWholePressed = true + + case .up: + dpadWholePressed = false + if dpadHighlight.contains(.up) { + dpadHighlight.remove(.up) + } else { + dpadHighlight.insert(.up) + } + + case .down: + dpadWholePressed = false + if dpadHighlight.contains(.down) { + dpadHighlight.remove(.down) + } else { + dpadHighlight.insert(.down) + } + + case .left: + dpadWholePressed = false + if dpadHighlight.contains(.left) { + dpadHighlight.remove(.left) + } else { + dpadHighlight.insert(.left) + } + + case .right: + dpadWholePressed = false + if dpadHighlight.contains(.right) { + dpadHighlight.remove(.right) + } else { + dpadHighlight.insert(.right) + } + + default: + break + } + } + + private func makeRect(centerX: CGFloat, centerY: CGFloat, width: CGFloat, height: CGFloat) -> CGRect { + CGRect( + x: centerX - width * 0.5, + y: centerY - height * 0.5, + width: width, + height: height + ) + } + + private func triggerRects( + containerW: CGFloat, + containerH: CGFloat, + w: CGFloat, + h: CGFloat + ) -> (left: CGRect, right: CGRect) { + + let triggerWidth = w * Layout.topTriggerStubWidthRatio + let triggerHeight = h * Layout.topTriggerStubHeightRatio + let triggerSpacing = w * Layout.topTriggerStubSpacingRatio + + let padX = w * Layout.topTriggerStubHorizontalPaddingRatio + let offsetX = h * Layout.topTriggerStubCenterXRatio + let offsetY = h * Layout.topTriggerStubCenterYRatio + + let finalOffsetX = offsetX * 2 - containerW * 0.012 + let finalOffsetY = offsetY * 2 + + let stackContentWidth = triggerWidth * 2 + triggerSpacing + let stackFrameWidth = stackContentWidth + padX * 2 + + let leftCenterXInStack = (containerW - stackFrameWidth) * 0.5 + padX + triggerWidth * 0.5 + let rightCenterXInStack = leftCenterXInStack + triggerWidth + triggerSpacing + + let triggerCenterY = containerH * 0.5 + finalOffsetY + let leftTriggerCenterX = leftCenterXInStack + finalOffsetX + let rightTriggerCenterX = rightCenterXInStack + finalOffsetX + + return ( + makeRect(centerX: leftTriggerCenterX, centerY: triggerCenterY, width: triggerWidth, height: triggerHeight), + makeRect(centerX: rightTriggerCenterX, centerY: triggerCenterY, width: triggerWidth, height: triggerHeight) + ) + } + + private func handleShoulderTap( + localPoint: CGPoint, + side: GamepadToggleTarget, + shoulderOverlaySize: CGSize, + shoulderOffsetX: CGFloat, + shoulderOffsetY: CGFloat, + containerW: CGFloat, + containerH: CGFloat, + triggerRect: CGRect + ) { + let overlayOriginX = (containerW - shoulderOverlaySize.width) * 0.5 + shoulderOffsetX - containerW * 0.012 + let overlayOriginY = (containerH - shoulderOverlaySize.height) * 0.5 + shoulderOffsetY + + let pointInContainer = CGPoint( + x: overlayOriginX + localPoint.x, + y: overlayOriginY + localPoint.y + ) + + if pointInContainer.x >= triggerRect.minX && pointInContainer.x <= triggerRect.maxX { + switch side { + case .leftShoulder: + handleTappedWidgets([.leftTrigger, .ltPad], sourceIsDPad: false) + case .rightShoulder: + handleTappedWidgets([.rightTrigger, .rtPad], sourceIsDPad: false) + default: + toggle(side) + } + } else { + toggle(side) + } + } + + var body: some View { + GeometryReader { geo in + let containerW = geo.size.width + let containerH = geo.size.height + let contentScale = Layout.innerContentScale + + let w = containerW * contentScale + let h = containerH * contentScale + + let cornerRadius = h * Layout.bodyCornerRadiusRatio + + // 尺寸 + let leftStickSize = w * Layout.leftStickSizeRatio + let rightStickSize = w * Layout.rightStickSizeRatio + let dpadSize = w * Layout.dpadSizeRatio + let abxySize = w * Layout.abxySizeRatio + let smallTopButtonDiameter = w * Layout.smallTopButtonDiameterRatio + + // 位置约束 + let sharedLowerControlCenterY = h * Layout.lowerControlCenterYRatio + let lowerControlHalfSpacing = w * Layout.lowerControlHalfSpacingRatio + + let dpadCenterX = -lowerControlHalfSpacing + let rightStickCenterX = lowerControlHalfSpacing + + let sharedTopButtonCenterY = h * Layout.topButtonCenterYRatio + let minusButtonCenterX = dpadCenterX + let plusButtonCenterX = rightStickCenterX + let ds4TouchpadCenterX = (dpadCenterX + rightStickCenterX) * 0.5 + let ds4TouchpadWidth = w * Layout.ds4TouchpadWidthRatio + let ds4TouchpadHeight = h * Layout.ds4TouchpadHeightRatio + let ds4TouchpadCenterY = (-containerH * 0.5) + (ds4TouchpadHeight * 0.5) + + let leftStickCenterX = w * Layout.leftStickCenterXRatio + let leftStickCenterY = h * Layout.leftStickCenterYRatio + + let homeCenterX = w * Layout.homeCenterXRatio + let homeCenterY = h * Layout.homeCenterYRatio + + // ABXY:与 Home 垂直对齐;与左摇杆同一水平轴 + let abxyCenterX = homeCenterX + let abxyCenterY = leftStickCenterY + + let shoulderBarWidth = w * Layout.shoulderBarWidthRatio + let shoulderBarHeight = h * Layout.shoulderBarHeightRatio + let shoulderBarCenterY = h * Layout.shoulderBarCenterYRatio + + let shoulderHalfX = (w * 0.5) - (w * Layout.shoulderBarInsetFromSideRatio) - (shoulderBarWidth * 0.5) + let leftShoulderX = -shoulderHalfX + let rightShoulderX = shoulderHalfX + + let triggerRects = triggerRects( + containerW: containerW, + containerH: containerH, + w: w, + h: h + ) + + let triggerStubWidth = w * Layout.topTriggerStubWidthRatio + let triggerStubHeight = h * Layout.topTriggerStubHeightRatio + let triggerStubSpacing = w * Layout.topTriggerStubSpacingRatio + + let shoulderHitWidth = shoulderBarWidth * 1.25 + let shoulderHitHeight = max(28, shoulderBarHeight * 5.5) + + ZStack { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.black.opacity(0.12)) + .blur(radius: 18) + .offset(y: h * Layout.bodyShadowYOffsetRatio) + + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.25, green: 0.86, blue: 0.90), + Color(red: 0.17, green: 0.76, blue: 0.82) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.22), lineWidth: 1.2) + ) + + RoundedRectangle(cornerRadius: cornerRadius * 0.88, style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 2) + .padding(w * Layout.innerStrokeInsetRatio) + + ZStack { + RadialGradient( + gradient: Gradient(colors: [ + Color.white.opacity(0.14), + Color.clear + ]), + center: .top, + startRadius: 10, + endRadius: w * Layout.radialHighlightEndRadiusRatio + ) + .clipShape( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ) + + // 保持你原来的 trigger 布局,不改位置 + HStack(spacing: triggerStubSpacing) { + TopTriggerStub( + width: triggerStubWidth, + height: triggerStubHeight, + highlightMode: leftTriggerHighlight + ) + + TopTriggerStub( + width: triggerStubWidth, + height: triggerStubHeight, + highlightMode: rightTriggerHighlight + ) + } + .padding(.horizontal, w * Layout.topTriggerStubHorizontalPaddingRatio) + .offset(y: h * Layout.topTriggerStubCenterYRatio) + .offset(x: h * Layout.topTriggerStubCenterXRatio) + .padding(.horizontal, w * Layout.topTriggerStubHorizontalPaddingRatio) + .offset(y: h * Layout.topTriggerStubCenterYRatio) + .offset(x: h * Layout.topTriggerStubCenterXRatio) + .zIndex(0) + + // 肩键:如果触点落在对应 trigger frame,就优先切换 trigger + ShoulderButtonBarButton( + isHighlighted: activeButtons.contains(.leftShoulder), + width: shoulderBarWidth, + height: shoulderBarHeight + ) { localPoint in + handleShoulderTap( + localPoint: localPoint, + side: .leftShoulder, + shoulderOverlaySize: CGSize(width: shoulderHitWidth, height: shoulderHitHeight), + shoulderOffsetX: leftShoulderX * 1.005, + shoulderOffsetY: shoulderBarCenterY * 0.902, + containerW: containerW, + containerH: containerH, + triggerRect: triggerRects.left + ) + } + .offset(x: leftShoulderX * 1.005, y: shoulderBarCenterY * 0.902) + + ShoulderButtonBarButton( + isHighlighted: activeButtons.contains(.rightShoulder), + width: shoulderBarWidth, + height: shoulderBarHeight, + flipped: true + ) { localPoint in + handleShoulderTap( + localPoint: localPoint, + side: .rightShoulder, + shoulderOverlaySize: CGSize(width: shoulderHitWidth, height: shoulderHitHeight), + shoulderOffsetX: rightShoulderX * 1.065, + shoulderOffsetY: shoulderBarCenterY * 0.902, + containerW: containerW, + containerH: containerH, + triggerRect: triggerRects.right + ) + } + .offset(x: rightShoulderX * 1.065, y: shoulderBarCenterY * 0.902) + + SmallRoundIconButton( + symbol: "−", + diameter: smallTopButtonDiameter, + isHighlighted: activeButtons.contains(.select) + ) + .offset(x: minusButtonCenterX, y: sharedTopButtonCenterY) + .onTapGesture { + toggle(.select) + } + + SmallRoundIconButton( + symbol: "+", + diameter: smallTopButtonDiameter, + isHighlighted: activeButtons.contains(.start) + ) + .offset(x: plusButtonCenterX, y: sharedTopButtonCenterY) + .onTapGesture { + toggle(.start) + } + + if gamepadType == .ps { + DS4TouchpadView( + width: ds4TouchpadWidth, + height: ds4TouchpadHeight, + highlightMode: ds4TouchHighlight + ) + .offset(x: ds4TouchpadCenterX, y: ds4TouchpadCenterY) + .onTapGesture { + handleTappedWidgets([.ds4Touchpad, .ds4TouchpadButton], sourceIsDPad: false) + } + } + + StickView(highlightMode: leftStickHighlight) + .frame(width: leftStickSize, height: leftStickSize) + .offset(x: leftStickCenterX, y: leftStickCenterY) + .onTapGesture { + handleTappedWidgets([.ls, .lswheel, .lsPad], sourceIsDPad: false) + } + + DPadXboxStyleView( + highlight: dpadHighlight, + wholePressed: dpadWholePressed + ) { tappedRegion in + toggleDPadSelection(for: tappedRegion) + } + .frame(width: dpadSize, height: dpadSize) + .offset(x: dpadCenterX, y: sharedLowerControlCenterY) + + ABXYClusterView( + gamepadType: gamepadType, + highlightedButtons: activeButtons, + onTap: { target in + toggle(target) + } + ) + .frame(width: abxySize, height: abxySize) + .offset(x: abxyCenterX, y: abxyCenterY) + + StickView(highlightMode: rightStickHighlight) + .frame(width: rightStickSize, height: rightStickSize) + .offset(x: rightStickCenterX, y: sharedLowerControlCenterY) + .onTapGesture { + handleTappedWidgets([.rs, .rsPad, .rsvPad], sourceIsDPad: false) + } + + HomeButtonView( + diameter: smallTopButtonDiameter, + isHighlighted: activeButtons.contains(.home) + ) + .offset(x: homeCenterX, y: homeCenterY) + .onTapGesture { + toggle(.home) + } + } + .frame(width: containerW, height: containerH) + .offset(x: -containerW * 0.012) + } + .frame(width: containerW, height: containerH) + } + .aspectRatio(Layout.aspectRatio, contentMode: .fit) + .overlay( + Group { + if showWidgetPicker { + GamepadWidgetPickerOverlay( + widgets: pendingWidgetOptions, + onSelect: { widget in + selectWidget(widget) + }, + onReset: { + resetCurrentWidgetPickerSelection() + }, + onCancel: { + showWidgetPicker = false + } + ) + .zIndex(999) + } + } + ) + .onReceive(Just(resetToken)) { token in + guard token != lastAppliedResetToken else { return } + lastAppliedResetToken = token + resetAllSelections() + } + .onReceive(Just(selectionSyncToken)) { token in + guard token != lastAppliedSelectionSyncToken else { return } + lastAppliedSelectionSyncToken = token + synchronizeSelectionHighlights() + } + .onReceive(Just(externalDeselectionToken)) { token in + guard token != lastAppliedExternalDeselectionToken else { return } + lastAppliedExternalDeselectionToken = token + if let command = externalDeselectionCommand, !command.isEmpty { + deselectCommand(command) + } + } + .onAppear { + synchronizeSelectionHighlights() + } + } + + private func resetAllSelections() { + activeButtons.removeAll() + dpadHighlight = .none + dpadWholePressed = false + leftStickHighlight = .none + rightStickHighlight = .none + pendingWidgetOptions.removeAll() + showWidgetPicker = false + pickerSourceIsDPad = false + leftTriggerHighlight = .none + rightTriggerHighlight = .none + ds4TouchHighlight = .none + AbstractGamepadView.tappedWidgets = [] + AbstractGamepadView.selectedCmd = "" + } + + private func synchronizeSelectionHighlights() { + resetAllSelections() + + guard let isCommandSelected = isCommandSelected else { return } + + if isCommandSelected(GamepadWidget.a.cmd) { activeButtons.insert(.a) } + if isCommandSelected(GamepadWidget.b.cmd) { activeButtons.insert(.b) } + if isCommandSelected(GamepadWidget.x.cmd) { activeButtons.insert(.x) } + if isCommandSelected(GamepadWidget.y.cmd) { activeButtons.insert(.y) } + if isCommandSelected(GamepadWidget.select.cmd) { activeButtons.insert(.select) } + if isCommandSelected(GamepadWidget.start.cmd) { activeButtons.insert(.start) } + if isCommandSelected(GamepadWidget.home.cmd) { activeButtons.insert(.home) } + if isCommandSelected(GamepadWidget.leftShoulder.cmd) { activeButtons.insert(.leftShoulder) } + if isCommandSelected(GamepadWidget.rightShoulder.cmd) { activeButtons.insert(.rightShoulder) } + + if isCommandSelected(GamepadWidget.dPad.cmd) { + dpadWholePressed = true + } else { + if isCommandSelected(GamepadWidget.up.cmd) { dpadHighlight.insert(.up) } + if isCommandSelected(GamepadWidget.down.cmd) { dpadHighlight.insert(.down) } + if isCommandSelected(GamepadWidget.left.cmd) { dpadHighlight.insert(.left) } + if isCommandSelected(GamepadWidget.right.cmd) { dpadHighlight.insert(.right) } + } + + if isCommandSelected(GamepadWidget.ls.cmd) { + leftStickHighlight = .outerOnly + } + if isCommandSelected(GamepadWidget.lsPad.cmd) || isCommandSelected(GamepadWidget.lswheel.cmd) { + leftStickHighlight = leftStickHighlight == .outerOnly ? .both : .thumbOnly + } + + if isCommandSelected(GamepadWidget.rs.cmd) { + rightStickHighlight = .outerOnly + } + if isCommandSelected(GamepadWidget.rsPad.cmd) || isCommandSelected(GamepadWidget.rsvPad.cmd) { + rightStickHighlight = rightStickHighlight == .outerOnly ? .both : .thumbOnly + } + + if isCommandSelected(GamepadWidget.leftTrigger.cmd) { + leftTriggerHighlight = .button + } else if isCommandSelected(GamepadWidget.ltPad.cmd) { + leftTriggerHighlight = .pad + } + + if isCommandSelected(GamepadWidget.rightTrigger.cmd) { + rightTriggerHighlight = .button + } else if isCommandSelected(GamepadWidget.rtPad.cmd) { + rightTriggerHighlight = .pad + } + + if isCommandSelected(GamepadWidget.ds4TouchpadButton.cmd) { + ds4TouchHighlight = .button + } else if isCommandSelected(GamepadWidget.ds4Touchpad.cmd) { + ds4TouchHighlight = .pad + } + } + + private func deselectCommand(_ command: String) { + switch command { + case GamepadWidget.a.cmd: activeButtons.remove(.a) + case GamepadWidget.b.cmd: activeButtons.remove(.b) + case GamepadWidget.x.cmd: activeButtons.remove(.x) + case GamepadWidget.y.cmd: activeButtons.remove(.y) + case GamepadWidget.select.cmd: activeButtons.remove(.select) + case GamepadWidget.start.cmd: activeButtons.remove(.start) + case GamepadWidget.home.cmd: activeButtons.remove(.home) + case GamepadWidget.leftShoulder.cmd: activeButtons.remove(.leftShoulder) + case GamepadWidget.rightShoulder.cmd: activeButtons.remove(.rightShoulder) + case GamepadWidget.dPad.cmd: + dpadWholePressed = false + dpadHighlight = .none + case GamepadWidget.up.cmd: + dpadHighlight.remove(.up) + case GamepadWidget.down.cmd: + dpadHighlight.remove(.down) + case GamepadWidget.left.cmd: + dpadHighlight.remove(.left) + case GamepadWidget.right.cmd: + dpadHighlight.remove(.right) + case GamepadWidget.ls.cmd: + if leftStickHighlight == .both { + leftStickHighlight = .thumbOnly + } else if leftStickHighlight == .outerOnly { + leftStickHighlight = .none + } + case GamepadWidget.lsPad.cmd, GamepadWidget.lswheel.cmd: + if leftStickHighlight == .both { + leftStickHighlight = .outerOnly + } else if leftStickHighlight == .thumbOnly { + leftStickHighlight = .none + } + case GamepadWidget.rs.cmd: + if rightStickHighlight == .both { + rightStickHighlight = .thumbOnly + } else if rightStickHighlight == .outerOnly { + rightStickHighlight = .none + } + case GamepadWidget.rsPad.cmd, GamepadWidget.rsvPad.cmd: + if rightStickHighlight == .both { + rightStickHighlight = .outerOnly + } else if rightStickHighlight == .thumbOnly { + rightStickHighlight = .none + } + case GamepadWidget.leftTrigger.cmd: + leftTriggerHighlight = .none + case GamepadWidget.ltPad.cmd: + leftTriggerHighlight = .none + case GamepadWidget.rightTrigger.cmd: + rightTriggerHighlight = .none + case GamepadWidget.rtPad.cmd: + rightTriggerHighlight = .none + case GamepadWidget.ds4Touchpad.cmd: + ds4TouchHighlight = .none + case GamepadWidget.ds4TouchpadButton.cmd: + ds4TouchHighlight = .none + default: + break + } + + if command == GamepadWidget.up.cmd || command == GamepadWidget.down.cmd || command == GamepadWidget.left.cmd || command == GamepadWidget.right.cmd { + dpadWholePressed = false + } + + if AbstractGamepadView.selectedCmd == command { + AbstractGamepadView.selectedCmd = "" + } + } +} + +@available(iOS 13.0, *) +struct GamepadWidgetPickerOverlay: View { + let widgets: [GamepadWidget] + let onSelect: (GamepadWidget) -> Void + let onReset: () -> Void + let onCancel: () -> Void + + private var isPhone: Bool { + UIDevice.current.userInterfaceIdiom == .phone + } + + var body: some View { + let cardSpacing: CGFloat = isPhone ? 8 : 14 + let titleFontSize: CGFloat = isPhone ? 15 : 18 + let optionSpacing: CGFloat = isPhone ? 6 : 10 + let optionHeight: CGFloat = isPhone ? 34 : 44 + let optionHorizontalPadding: CGFloat = isPhone ? 9 : 14 + let optionTitleFontSize: CGFloat = isPhone ? 12 : 15 + let optionCodeFontSize: CGFloat = isPhone ? 10 : 12 + let actionButtonHeight: CGFloat = isPhone ? 32 : 42 + let actionButtonFontSize: CGFloat = isPhone ? 12 : 15 + let cardPadding: CGFloat = isPhone ? 10 : 18 + let cardMaxWidth: CGFloat = isPhone ? 250 : 320 + let cardCornerRadius: CGFloat = isPhone ? 18 : 22 + let horizontalInset: CGFloat = isPhone ? 12 : 24 + let topInset: CGFloat = isPhone ? 8 : 0 + + ZStack { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + onCancel() + } + + VStack(spacing: 0) { + VStack(spacing: cardSpacing) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Select Control")) + .font(.system(size: titleFontSize, weight: .bold, design: .rounded)) + .foregroundColor(.white) + + VStack(spacing: optionSpacing) { + ForEach(widgets, id: \.self) { widget in + Button(action: { + onSelect(widget) + }) { + HStack(spacing: isPhone ? 8 : 12) { + Text(widget.description) + .font(.system(size: optionTitleFontSize, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.6) + + Spacer() + + Text(widget.cmd) + .font(.system(size: optionCodeFontSize, weight: .bold, design: .rounded)) + .foregroundColor(Color.white.opacity(0.72)) + .lineLimit(1) + } + .padding(.horizontal, optionHorizontalPadding) + .frame(height: optionHeight) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + + HStack(spacing: isPhone ? 8 : 12) { + Button(action: { + onCancel() + }) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Cancel")) + .font(.system(size: actionButtonFontSize, weight: .bold, design: .rounded)) + .foregroundColor(Color.white.opacity(0.9)) + .frame(maxWidth: .infinity) + .frame(height: actionButtonHeight) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(0.16)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: { + onReset() + }) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Reset")) + .font(.system(size: actionButtonFontSize, weight: .bold, design: .rounded)) + .foregroundColor(Color.white.opacity(0.9)) + .frame(maxWidth: .infinity) + .frame(height: actionButtonHeight) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(0.16)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(cardPadding) + .frame(maxWidth: cardMaxWidth) + .background( + RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.15, green: 0.46, blue: 0.50), + Color(red: 0.11, green: 0.34, blue: 0.39) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 1.2) + ) + ) + .shadow(color: Color.black.opacity(0.22), radius: 20, x: 0, y: 10) + .padding(.top, topInset) + .padding(.horizontal, horizontalInset) + + Spacer(minLength: 0) + } + } + } +} + +// MARK: - 顶部小突起 + +@available(iOS 13.0, *) +struct TopTriggerStub: View { + var width: CGFloat + var height: CGFloat + var highlightMode: TriggerHighlightMode = .none + + private var isHighlighted: Bool { + highlightMode != .none + } + + var body: some View { + RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: isHighlighted ? [ + highlightMode == .pad ? GamepadPadHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop, + highlightMode == .pad ? GamepadPadHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom + ] : [ + Color.white.opacity(0.97), + Color(red: 0.86, green: 0.86, blue: 0.88) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(width: width, height: height) + .overlay( + RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) + .stroke( + highlightMode == .pad ? GamepadPadHighlightStyle.stroke : (isHighlighted ? GamepadButtonHighlightStyle.stroke : Color.black.opacity(0.08)), + lineWidth: isHighlighted ? 1.4 : 1 + ) + ) + .overlay( + LinearGradient( + gradient: Gradient(colors: [ + Color.white.opacity(isHighlighted ? 0.18 : 0.28), + Color.clear + ]), + startPoint: .top, + endPoint: .bottom + ) + .clipShape( + RoundedRectangle(cornerRadius: height * 0.32, style: .continuous) + ) + ) + .shadow(color: Color.black.opacity(0.10), radius: 2, x: 0, y: 1) + } +} +// MARK: - 顶部 +/- 小按钮 + +@available(iOS 13.0, *) +struct SmallRoundIconButton: View { + var symbol: String + var diameter: CGFloat = 35 + var isHighlighted: Bool = false + + var body: some View { + Circle() + .fill( + LinearGradient( + gradient: Gradient(colors: isHighlighted ? [ + Color(red: 1.00, green: 0.92, blue: 0.50), + Color(red: 0.95, green: 0.73, blue: 0.22) + ] : [ + Color(red: 0.23, green: 0.82, blue: 0.86), + Color(red: 0.16, green: 0.68, blue: 0.74) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + Circle() + .stroke( + isHighlighted ? Color.orange.opacity(0.7) : Color.black.opacity(0.16), + lineWidth: isHighlighted ? 1.5 : 1 + ) + ) + .overlay( + Circle() + .stroke(Color.white.opacity(isHighlighted ? 0.28 : 0.18), lineWidth: 1) + .padding(2) + ) + .overlay( + Text(symbol) + .font(.system(size: diameter * 0.65, weight: .bold, design: .rounded)) + .foregroundColor(isHighlighted ? Color.white.opacity(0.95) : Color.white.opacity(0.65)) + .offset(x: diameter * 0.013) + .offset(y: diameter * -0.042) + ) + .frame(width: diameter, height: diameter) + .shadow(color: Color.black.opacity(0.12), radius: 3, x: 0, y: 2) + } +} + +@available(iOS 13.0, *) +struct DS4TouchpadView: View { + private enum Ratio { + static let corner: CGFloat = 0.30 + static let insetStroke: CGFloat = 0.06 + static let dotInsetX: CGFloat = 0.07 + static let dotInsetY: CGFloat = 0.10 + static let dotColumns: Int = 20 + static let dotRows: Int = 8 + static let dotSizeFactor: CGFloat = 0.28 + } + + var width: CGFloat + var height: CGFloat + var highlightMode: DS4TouchHighlightMode = .none + + private var isHighlighted: Bool { + highlightMode != .none + } + + private var fillTop: Color { + highlightMode == .pad ? GamepadPadHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop + } + + private var fillBottom: Color { + highlightMode == .pad ? GamepadPadHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom + } + + private var strokeColor: Color { + highlightMode == .pad ? GamepadPadHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke + } + + private var dotColor: Color { + if isHighlighted { + return Color.white.opacity(0.28) + } + return Color.black.opacity(0.18) + } + + private var recessedShadowOpacity: Double { + isHighlighted ? 0.0 : 0.28 + } + + private var recessedShadowColor: Color { + Color(red: 0.16, green: 0.45, blue: 0.54).opacity(recessedShadowOpacity * 0.9) + } + + private var recessedHighlightColor: Color { + Color.white.opacity(isHighlighted ? 0.0 : 0.10) + } + + var body: some View { + let cornerRadius = height * Ratio.corner + let shape = DS4TouchpadShape(bottomCornerRadius: cornerRadius) + + shape + .fill( + LinearGradient( + gradient: Gradient(colors: isHighlighted ? [ + fillTop, + fillBottom + ] : [ + Color.clear, + Color.clear + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: width, height: height) + .contentShape(shape) + .overlay( + shape + .stroke( + isHighlighted ? strokeColor : Color.black.opacity(0.16), + lineWidth: isHighlighted ? 1.6 : 1 + ) + ) + .overlay( + DS4TouchpadShape(bottomCornerRadius: cornerRadius * 0.88) + .stroke(Color.white.opacity(isHighlighted ? 0.18 : 0.08), lineWidth: 1) + .padding(height * Ratio.insetStroke) + ) + .overlay( + dotPattern + .padding(.horizontal, width * Ratio.dotInsetX) + .padding(.vertical, height * Ratio.dotInsetY) + .clipShape( + DS4TouchpadShape(bottomCornerRadius: cornerRadius * 0.86) + ) + ) + .overlay( + shape + .stroke(recessedShadowColor, lineWidth: 1.2) + .offset(y: 0.8) + .opacity(isHighlighted ? 0.0 : 0.9) + ) + .overlay( + shape + .stroke(recessedHighlightColor, lineWidth: 0.8) + .offset(y: -0.4) + .opacity(isHighlighted ? 0.0 : 1.0) + ) + .shadow(color: Color(red: 0.14, green: 0.36, blue: 0.42).opacity(isHighlighted ? 0.10 : 0.010), radius: isHighlighted ? 3 : 0.6, x: 0, y: isHighlighted ? 2 : 0.2) + } + + private var dotPattern: some View { + GeometryReader { geo in + DS4TouchpadDotGridShape( + columns: Ratio.dotColumns, + rows: Ratio.dotRows, + dotSizeFactor: Ratio.dotSizeFactor + ) + .fill(dotColor) + } + } +} + +@available(iOS 13.0, *) +struct DS4TouchpadDotGridShape: Shape { + let columns: Int + let rows: Int + let dotSizeFactor: CGFloat + + func path(in rect: CGRect) -> Path { + guard columns > 0, rows > 0, rect.width > 0, rect.height > 0 else { + return Path() + } + + let cellWidth = rect.width / CGFloat(columns) + let cellHeight = rect.height / CGFloat(rows) + let dotDiameter = min(cellWidth, cellHeight) * dotSizeFactor + let radius = dotDiameter * 0.5 + + var path = Path() + + for row in 0.. Path { + let radius = min(bottomCornerRadius, rect.width * 0.5, rect.height) + + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - radius)) + path.addArc( + center: CGPoint(x: rect.maxX - radius, y: rect.maxY - radius), + radius: radius, + startAngle: .degrees(0), + endAngle: .degrees(90), + clockwise: false + ) + path.addLine(to: CGPoint(x: rect.minX + radius, y: rect.maxY)) + path.addArc( + center: CGPoint(x: rect.minX + radius, y: rect.maxY - radius), + radius: radius, + startAngle: .degrees(90), + endAngle: .degrees(180), + clockwise: false + ) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) + path.closeSubpath() + return path + } +} + +// MARK: - 摇杆 +@available(iOS 13.0, *) +private enum GamepadPadHighlightStyle { + static let fillTop = Color(red: 0.82, green: 0.84, blue: 1.00).opacity(0.92) + static let fillBottom = Color(red: 0.56, green: 0.60, blue: 0.94).opacity(0.84) + static let stroke = Color(red: 0.38, green: 0.42, blue: 0.82).opacity(0.90) +} + +@available(iOS 13.0, *) +private enum GamepadButtonHighlightStyle { + static let fillTop = Color(red: 1.00, green: 0.96, blue: 0.72) + static let fillBottom = Color(red: 0.95, green: 0.78, blue: 0.34) + static let stroke = Color.orange.opacity(0.75) +} + + +@available(iOS 13.0, *) +struct StickView: View { + + private struct Ratio { + static let base: CGFloat = 0.66 + static let shell: CGFloat = 0.60 + static let thumb: CGFloat = 0.45 + static let shadowOffsetX: CGFloat = 0 + static let shadowOffsetY: CGFloat = 0 + } + + var highlightMode: StickHighlightMode = .none + + private var outerHighlighted: Bool { + highlightMode == .outerOnly || highlightMode == .both + } + + private var thumbHighlighted: Bool { + highlightMode == .thumbOnly || highlightMode == .both + } + + var body: some View { + GeometryReader { geo in + let s = min(geo.size.width, geo.size.height) + + ZStack { + Circle() + .fill(Color.black.opacity(0.18)) + .frame(width: s * Ratio.base, height: s * Ratio.base) + .offset( + x: s * Ratio.shadowOffsetX, + y: s * Ratio.shadowOffsetY + ) + + Circle() + .fill( + LinearGradient( + gradient: Gradient(colors: outerHighlighted ? [ + GamepadButtonHighlightStyle.fillTop, + GamepadButtonHighlightStyle.fillBottom + ] : [ + Color.white.opacity(0.98), + Color(red: 0.88, green: 0.88, blue: 0.90) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: s * Ratio.shell, height: s * Ratio.shell) + .overlay( + Circle() + .stroke( + outerHighlighted ? GamepadButtonHighlightStyle.stroke : Color.black.opacity(0.10), + lineWidth: outerHighlighted ? 1.5 : 1 + ) + ) + + Circle() + .fill( + LinearGradient( + gradient: Gradient(colors: thumbHighlighted ? [ + GamepadPadHighlightStyle.fillTop, + GamepadPadHighlightStyle.fillBottom + ] : [ + Color(red: 0.95, green: 0.95, blue: 0.96), + Color(red: 0.86, green: 0.86, blue: 0.88) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: s * Ratio.thumb, height: s * Ratio.thumb) + .overlay( + Circle() + .stroke( + thumbHighlighted ? GamepadPadHighlightStyle.stroke : Color.black.opacity(0.06), + lineWidth: 1 + ) + ) + } + .frame(width: geo.size.width, height: geo.size.height) + } + } +}// MARK: - 十字键 + +@available(iOS 13.0, *) +struct DPadXboxStyleView: View { + var highlight: DPadHighlight = .none + var wholePressed: Bool = false + var onTapRegion: ((DPadHighlight) -> Void)? = nil + + var body: some View { + GeometryReader { geo in + let s = min(geo.size.width, geo.size.height) + let arm = s * 0.78 + let thickness = s * 0.30 + + ZStack { + DPadCrossShape() + .fill(Color.black.opacity(0.25)) + .frame(width: arm, height: arm) + .offset(x: -2, y: 3) + .blur(radius: 1) + + DPadCrossShape() + .fill(Color.black.opacity(0.90)) + .frame(width: arm, height: arm) + + DPadCrossBaseView( + arm: arm, + highlight: highlight, + wholePressed: wholePressed + ) + + Group { + Text("▴").offset(y: -thickness * 0.95) + Text("▾").offset(y: thickness * 0.95) + Text("◂").offset(x: -thickness * 0.95) + Text("▸").offset(x: thickness * 0.95) + } + .font(.system(size: s * 0.14, weight: .bold)) + .foregroundColor( + highlight == .none + ? Color.black.opacity(0.12) + : Color.black.opacity(0.22) + ) + } + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { value in + let region = resolveDPadRegion( + point: value.location, + size: geo.size + ) + onTapRegion?(region) + } + ) + .frame(width: geo.size.width, height: geo.size.height) + } + } + + private func resolveDPadRegion(point: CGPoint, size: CGSize) -> DPadHighlight { + let s = min(size.width, size.height) + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let dx = point.x - center.x + let dy = point.y - center.y + + let centerHalf: CGFloat = s * 0.15 + if abs(dx) <= centerHalf && abs(dy) <= centerHalf { + return .all + } + + if abs(dx) > abs(dy) { + return dx < 0 ? .left : .right + } else { + return dy < 0 ? .up : .down + } + } +} + +@available(iOS 13.0.0, *) +struct DPadCrossBaseView: View { + let arm: CGFloat + let highlight: DPadHighlight + let wholePressed: Bool + + var body: some View { + ZStack { + dpadSegment(.up) + dpadSegment(.down) + dpadSegment(.left) + dpadSegment(.right) + centerSquare + } + .frame(width: arm - 2, height: arm - 1) + } + + @ViewBuilder + private func dpadSegment(_ dir: DPadHighlight) -> some View { + let isOn = wholePressed || highlight.contains(dir) + let usePadHighlight = wholePressed + + DPadSegmentShape(direction: dir) + .fill( + LinearGradient( + gradient: Gradient(colors: isOn ? [ + usePadHighlight ? GamepadPadHighlightStyle.fillTop : GamepadButtonHighlightStyle.fillTop, + usePadHighlight ? GamepadPadHighlightStyle.fillBottom : GamepadButtonHighlightStyle.fillBottom + ] : [ + Color.white.opacity(0.98), + Color(red: 0.88, green: 0.88, blue: 0.90) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + DPadSegmentShape(direction: dir) + .stroke( + isOn ? (usePadHighlight ? GamepadPadHighlightStyle.stroke : GamepadButtonHighlightStyle.stroke) : Color.clear, + lineWidth: 1 + ) + ) + } + + private var centerSquare: some View { + let isOn = wholePressed + + return RoundedRectangle(cornerRadius: arm * 0.04, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: isOn ? [ + GamepadPadHighlightStyle.fillTop, + GamepadPadHighlightStyle.fillBottom + ] : [ + Color.white.opacity(0.98), + Color(red: 0.87, green: 0.87, blue: 0.89) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: arm * 0.33, height: arm * 0.33) + .overlay( + RoundedRectangle(cornerRadius: arm * 0.04, style: .continuous) + .stroke(isOn ? GamepadPadHighlightStyle.stroke : Color.black.opacity(0.05), lineWidth: 1) + ) + } +} + +@available(iOS 13.0.0, *) +struct DPadCrossShape: Shape { + func path(in rect: CGRect) -> Path { + let w = rect.width + let h = rect.height + let half = w * 0.5 + let thickness = w * 0.33 + let tHalf = thickness * 0.5 + let radius = w * 0.06 + + func rr(_ x: CGFloat, _ y: CGFloat, _ width: CGFloat, _ height: CGFloat) -> Path { + Path( + roundedRect: CGRect(x: x, y: y, width: width, height: height), + cornerRadius: radius + ) + } + + var p = Path() + p.addPath(rr(half - tHalf, 0, thickness, h)) + p.addPath(rr(0, half - tHalf, w, thickness)) + return p + } +} + +@available(iOS 13.0.0, *) +struct DPadSegmentShape: Shape { + let direction: DPadHighlight + + func path(in rect: CGRect) -> Path { + let w = rect.width + let h = rect.height + let thickness = w * 0.33 + let halfT = thickness / 2 + let cx = w / 2 + let cy = h / 2 + let r = w * 0.06 + + var p = Path() + + switch direction { + case .up: + p.addRoundedRect( + in: CGRect(x: cx - halfT, y: 0, width: thickness, height: cy), + cornerSize: CGSize(width: r, height: r) + ) + case .down: + p.addRoundedRect( + in: CGRect(x: cx - halfT, y: cy, width: thickness, height: cy), + cornerSize: CGSize(width: r, height: r) + ) + case .left: + p.addRoundedRect( + in: CGRect(x: 0, y: cy - halfT, width: cx, height: thickness), + cornerSize: CGSize(width: r, height: r) + ) + case .right: + p.addRoundedRect( + in: CGRect(x: cx, y: cy - halfT, width: cx, height: thickness), + cornerSize: CGSize(width: r, height: r) + ) + default: + break + } + + return p + } +} + +// MARK: - ABXY + +@available(iOS 13.0, *) +struct ABXYClusterView: View { + let gamepadType: GamepadType + let highlightedButtons: Set + let onTap: (GamepadToggleTarget) -> Void + + var body: some View { + GeometryReader { geo in + let s = min(geo.size.width, geo.size.height) + let offset = s * 0.3 + let size = s * 0.31 + + ZStack { + LetterButton(title: leftButtonTitle, size: size, isHighlighted: highlightedButtons.contains(.x)) + .frame(width: size, height: size) + .offset(x: -offset, y: 0) + .onTapGesture { onTap(.x) } + + LetterButton(title: topButtonTitle, size: size, isHighlighted: highlightedButtons.contains(.y)) + .frame(width: size, height: size) + .offset(x: 0, y: -offset) + .onTapGesture { onTap(.y) } + + LetterButton(title: rightButtonTitle, size: size, isHighlighted: highlightedButtons.contains(.b)) + .frame(width: size, height: size) + .offset(x: offset, y: 0) + .onTapGesture { onTap(.b) } + + LetterButton(title: bottomButtonTitle, size: size, isHighlighted: highlightedButtons.contains(.a)) + .frame(width: size, height: size) + .offset(x: 0, y: offset) + .onTapGesture { onTap(.a) } + } + .frame(width: geo.size.width, height: geo.size.height) + } + } + + private var leftButtonTitle: String { + gamepadType == .ps ? "□" : "X" + } + + private var topButtonTitle: String { + gamepadType == .ps ? "△" : "Y" + } + + private var rightButtonTitle: String { + gamepadType == .ps ? "○" : "B" + } + + private var bottomButtonTitle: String { + gamepadType == .ps ? "×" : "A" + } +} + +@available(iOS 13.0, *) +struct LetterButton: View { + var title: String + var size: CGFloat + var isHighlighted: Bool = false + + var body: some View { + ZStack { + Circle() + .fill(Color.black.opacity(0.20)) + .offset(x: -1, y: 3) + .blur(radius: 1) + + Circle() + .fill( + LinearGradient( + gradient: Gradient(colors: isHighlighted ? [ + Color(red: 1.00, green: 0.96, blue: 0.74), + Color(red: 0.95, green: 0.78, blue: 0.32) + ] : [ + Color.white.opacity(0.98), + Color(red: 0.88, green: 0.88, blue: 0.90) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + Circle() + .stroke( + isHighlighted ? Color.orange.opacity(0.75) : Color.black.opacity(0.12), + lineWidth: isHighlighted ? 1.5 : 1 + ) + ) + + Text(title) + .font(.system(size: size * 0.53, weight: .medium, design: .rounded)) + .foregroundColor(isHighlighted ? Color.black.opacity(0.58) : Color.black.opacity(0.28)) + } + } +} + +// MARK: - Home Button + +@available(iOS 13.0, *) +struct HomeButtonView: View { + var diameter: CGFloat + var isHighlighted: Bool = false + + private var innerInset: CGFloat { diameter * 0.06 } + private var shadowOffsetY: CGFloat { diameter * 0.05 } + + var body: some View { + ZStack { + Circle() + .fill(Color.black.opacity(0.18)) + .offset(y: shadowOffsetY) + .blur(radius: 1) + + Circle() + .fill( + LinearGradient( + gradient: Gradient(colors: isHighlighted ? [ + Color(red: 1.00, green: 0.92, blue: 0.52), + Color(red: 0.96, green: 0.74, blue: 0.24) + ] : [ + Color(red: 0.25, green: 0.86, blue: 0.90), + Color(red: 0.18, green: 0.72, blue: 0.78) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + Circle() + .stroke( + isHighlighted ? Color.orange.opacity(0.75) : Color.black.opacity(0.16), + lineWidth: isHighlighted ? 1.5 : 1 + ) + ) + .overlay( + Circle() + .stroke(Color.white.opacity(isHighlighted ? 0.28 : 0.22), lineWidth: 1) + .padding(innerInset) + ) + + Image(systemName: "house") + .font(.system(size: diameter * 0.45, weight: .bold)) + .foregroundColor(.white.opacity(isHighlighted ? 1.0 : 0.9)) + } + .frame(width: diameter, height: diameter) + } +} + +// MARK: - 肩键 +@available(iOS 13.0, *) +struct ShoulderButtonBarButton: View { + var isHighlighted: Bool + var width: CGFloat + var height: CGFloat + var flipped: Bool = false + var action: (CGPoint) -> Void + + var body: some View { + ZStack { + ShoulderButtonBar(isHighlighted: isHighlighted) + .scaleEffect(x: flipped ? -1 : 1, y: 1) + .frame(width: width, height: height) + + Rectangle() + .fill(Color.white.opacity(0.001)) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { value in + action(value.location) + } + ) + } + .frame(width: width * 1.25, height: max(28, height * 5.5)) + } +} + +@available(iOS 13.0, *) +struct ShoulderButtonBar: View { + var isHighlighted: Bool = false + + var body: some View { + GeometryReader { geo in + let h = geo.size.height + let lineWidth = h * 1.16 + + let shape = ShoulderCenterLineShape( + startY: 4.5, + controlX: 0.27, + controlY: 0.35, + endY: 0.45, + horizontalTail: 0 + ) + + ZStack { + shape + .stroke( + Color.black.opacity(0.10), + style: StrokeStyle( + lineWidth: lineWidth, + lineCap: .round, + lineJoin: .round + ) + ) + .offset(y: h * 0.07) + .blur(radius: 1) + + shape + .stroke( + LinearGradient( + gradient: Gradient(colors: isHighlighted ? [ + Color(red: 1.00, green: 0.95, blue: 0.65), + Color(red: 0.95, green: 0.77, blue: 0.28) + ] : [ + .white.opacity(0.22), + Color(red: 0.86, green: 0.86, blue: 0.88) + ]), + startPoint: .top, + endPoint: .bottom + ), + style: StrokeStyle( + lineWidth: lineWidth, + lineCap: .round, + lineJoin: .round + ) + ) + + shape + .stroke( + LinearGradient( + gradient: Gradient(colors: [ + isHighlighted ? .white.opacity(0.34) : .white.opacity(0.56) + ]), + startPoint: .top, + endPoint: .bottom + ), + style: StrokeStyle( + lineWidth: lineWidth * 0.675, + lineCap: .round, + lineJoin: .round + ) + ) + .offset(y: -h * 0.05) + } + } + } +} + +@available(iOS 13.0, *) +struct ShoulderCenterLineShape: Shape { + + let startY: CGFloat + let controlX: CGFloat + let controlY: CGFloat + let endY: CGFloat + let horizontalTail: CGFloat + + func path(in rect: CGRect) -> Path { + let w = rect.width + let h = rect.height + + let left = w * 0.08 + let right = w * 0.94 + + let start = CGPoint(x: left, y: h * startY) + let control = CGPoint(x: w * controlX, y: h * controlY) + + let flattenStartX = right - w * 0.06 + let flattenStart = CGPoint(x: flattenStartX, y: h * endY) + + let end = CGPoint(x: right, y: h * endY) + let tailEnd = CGPoint(x: right + w * horizontalTail, y: h * endY) + + var p = Path() + p.move(to: start) + + p.addQuadCurve( + to: flattenStart, + control: control + ) + + p.addQuadCurve( + to: end, + control: CGPoint(x: right - w * 0.02, y: h * endY) + ) + + p.addLine(to: tailEnd) + return p + } +} + +// MARK: - Preview +@available(iOS 13.0, *) +struct XboxAbstractPadView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Color(red: 0.93, green: 0.93, blue: 0.94) + .edgesIgnoringSafeArea(.all) + + AbstractGamepadView(gamepadType: .ps) + .frame(width: 500, height: 330) + .padding(20) + } + .previewLayout(.sizeThatFits) + } +} diff --git a/VoidLinkSwiftUI/VirtualKeyboardView.swift b/VoidLinkSwiftUI/VirtualKeyboardView.swift new file mode 100644 index 00000000..6d3814cb --- /dev/null +++ b/VoidLinkSwiftUI/VirtualKeyboardView.swift @@ -0,0 +1,1531 @@ +// +// VirtualKeyboardView.swift +// VoidLink +// +// Created by True砖家 on 2026/3/25. +// Copyright © 2026 True砖家 @ Bilibili. All rights reserved. +// + +import SwiftUI +import Combine +import UIKit + +@available(iOS 13.0, *) +enum KeyboardType { + case win + case mac +} + +@available(iOS 13.0, *) +enum KeyRole { + case normal + case fn + case keyboardSwitch +} + +@available(iOS 13.0, *) +enum KeyVisualStyle { + case standard + case directionPad +} + +@available(iOS 13.0, *) +enum KeyboardSelectionFamily: Hashable { + case wasd + case arrow +} + +@available(iOS 13.0, *) +enum KeyboardSelectionState: Hashable { + case none + case single(String) + case pad +} + +@available(iOS 13.0, *) +struct KeyboardWidgetOption: Hashable { + let command: String + let description: String + let highlightedLabels: [String] + let family: KeyboardSelectionFamily + let selectionState: KeyboardSelectionState +} + +@available(iOS 13.0, *) +enum KeyHighlightStyle { + case none + case single + case pad +} + +@available(iOS 13.0, *) +enum VirtualKeyboardMode { + case picker + case typing +} + +@available(iOS 13.0, *) +struct Key: Identifiable { + let id = UUID() + let role: KeyRole + let identity: String? + let visualStyle: KeyVisualStyle + let label: String + let width: CGFloat + let sfSymbolName: String? + let winCmdString: String? + let macCmdString: String? + let winKeyCode: Int16? + let macKeyCode: Int16? + + init( + label: String, + role: KeyRole = .normal, + identity: String? = nil, + visualStyle: KeyVisualStyle = .standard, + width: CGFloat, + sfSymbolName: String? = nil, + winCmdString: String? = nil, + macCmdString: String? = nil, + winKeyCode: Int16? = nil, + macKeyCode: Int16? = nil + ) { + self.label = label + self.identity = identity + self.width = width + self.visualStyle = visualStyle + self.sfSymbolName = sfSymbolName + self.winCmdString = winCmdString + self.macCmdString = macCmdString + self.winKeyCode = winKeyCode + self.macKeyCode = macKeyCode + self.role = role + } + + func cmdString(for keyboardType: KeyboardType) -> String? { + switch keyboardType { + case .win: return winCmdString + case .mac: return macCmdString == nil ? winCmdString : macCmdString + } + } + + func keyCode(for keyboardType: KeyboardType) -> Int16? { + switch keyboardType { + case .win: return winKeyCode == nil ? 0xFF : winKeyCode + case .mac: return macKeyCode == nil ? 0xFF : macKeyCode + } + } +} + +@available(iOS 13.0, *) +struct Row: Identifiable { + let id = UUID() + let keys: [Key] +} + +@available(iOS 13.0, *) +struct VirtualKeyboardView: View { + let mode: VirtualKeyboardMode + var canSelectCommand: ((String) -> Bool)? = nil + var isCommandSelected: ((String) -> Bool)? = nil + var onCommandSelected: ((String) -> Void)? = nil + var onCommandDeselected: ((String) -> Void)? = nil + var resetToken: Int = 0 + var selectionSyncToken: Int = 0 + var externalDeselectionCommand: String? = nil + var externalDeselectionToken: Int = 0 + + @SwiftUI.State private var fnMode = false + @SwiftUI.State private var keyboardType: KeyboardType = .win + @SwiftUI.State private var showsMouseWidgets: Bool + @SwiftUI.State private var highlightedKeyIDs: Set = [] + @SwiftUI.State private var directionSingleKeyLabels: Set = [] + @SwiftUI.State private var padSelections: Set = [] + @SwiftUI.State private var pendingKeyboardOptions: [KeyboardWidgetOption] = [] + @SwiftUI.State private var pendingSelectionFamily: KeyboardSelectionFamily? + @SwiftUI.State private var showKeyboardWidgetPicker = false + @SwiftUI.State private var lastAppliedResetToken: Int = 0 + @SwiftUI.State private var lastAppliedSelectionSyncToken: Int = 0 + @SwiftUI.State private var lastAppliedExternalDeselectionToken: Int = 0 + + let keyHeight: CGFloat = 36 + let spacing: CGFloat = 6 + let unit: CGFloat = 36 // 1u = 正方形边长 + + static var tappedKeyLabels: [String] = [] + static var selectedCmd: String = "" + static var lastSelectionDisplayText: String? = nil + static var lastSelectionUsesMacLayout = false + static var lastSelectionFromMouseWidgets = false + static var lastSelectionUsesFnCommandDisplay = false + + init( + mode: VirtualKeyboardMode = .typing, + canSelectCommand: ((String) -> Bool)? = nil, + isCommandSelected: ((String) -> Bool)? = nil, + onCommandSelected: ((String) -> Void)? = nil, + onCommandDeselected: ((String) -> Void)? = nil, + resetToken: Int = 0, + selectionSyncToken: Int = 0, + externalDeselectionCommand: String? = nil, + externalDeselectionToken: Int = 0 + ) { + self.mode = mode + self.canSelectCommand = canSelectCommand + self.isCommandSelected = isCommandSelected + self.onCommandSelected = onCommandSelected + self.onCommandDeselected = onCommandDeselected + self.resetToken = resetToken + self.selectionSyncToken = selectionSyncToken + self.externalDeselectionCommand = externalDeselectionCommand + self.externalDeselectionToken = externalDeselectionToken + _showsMouseWidgets = SwiftUI.State(initialValue: mode == .picker) + } + + var body: some View { + Group { + if mode == .picker { + GeometryReader { proxy in + let isPhone = UIDevice.current.userInterfaceIdiom == .phone + let horizontalInset: CGFloat = isPhone ? 6 : 12 + let verticalInset: CGFloat = isPhone ? 6 : 12 + let availableWidth = max(proxy.size.width - horizontalInset * 2, 1) + let availableHeight = max(proxy.size.height - verticalInset * 2, 1) + let scale = min( + availableWidth / pickerContentWidth, + availableHeight / pickerContentHeight, + 1 + ) + + ZStack { + Color.clear + + pickerContent + .frame(width: pickerContentWidth, height: pickerContentHeight, alignment: .topLeading) + .scaleEffect(scale, anchor: .topLeading) + .frame( + width: pickerContentWidth * scale, + height: pickerContentHeight * scale, + alignment: .topLeading + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, horizontalInset) + .padding(.vertical, verticalInset) + } + } else { + ScrollView(.horizontal) { + HStack(alignment: .top, spacing: 20) { + mainBlock + navBlock + numpadBlock + } + .padding() + } + } + } + .overlay( + Group { + if showKeyboardWidgetPicker { + KeyboardWidgetPickerOverlay( + options: pendingKeyboardOptions, + onSelect: { option in + selectKeyboardWidget(option) + }, + onReset: { + resetCurrentKeyboardWidgetSelection() + }, + onCancel: { + showKeyboardWidgetPicker = false + } + ) + .zIndex(999) + } + } + ) + .onReceive(Just(resetToken)) { token in + guard token != lastAppliedResetToken else { return } + lastAppliedResetToken = token + resetAllSelections() + } + .onReceive(Just(selectionSyncToken)) { token in + guard token != lastAppliedSelectionSyncToken else { return } + lastAppliedSelectionSyncToken = token + synchronizeSelectionHighlights() + } + .onReceive(Just(externalDeselectionToken)) { token in + guard token != lastAppliedExternalDeselectionToken else { return } + lastAppliedExternalDeselectionToken = token + if let command = externalDeselectionCommand, !command.isEmpty { + deselectCommand(command) + } + } + .onAppear { + synchronizeSelectionHighlights() + } + } + + private var pickerContent: some View { + VStack(alignment: .leading, spacing: pickerSectionSpacing) { + mainBlock + pickerBottomRow + } + } + + private var pickerBottomRow: some View { + ZStack(alignment: .topLeading) { + Color.clear + + navBlock + .frame(width: navBlockWidth, alignment: .leading) + .offset(x: equalBottomRowGap) + + numpadBlock + .frame(width: numpadBlockWidth, alignment: .leading) + .offset(x: equalBottomRowGap * 2 + navBlockWidth) + } + .frame(width: mainBlockWidth, height: totalHeight, alignment: .topLeading) + } + + // MARK: - 主键区 + var mainBlock: some View { + VStack(spacing: spacing) { + ForEach(mainRows) { row in + HStack(spacing: spacing) { + ForEach(row.keys) { key in + KeyView( + key: key, + height: keyHeight, + keyboardType: keyboardType, + mode: mode, + usesDirectionPadBaseStyle: mode == .picker, + isActive: + (key.role == .fn && fnMode) + || (key.role == .keyboardSwitch && keyboardType == .mac), + highlightStyle: highlightStyle(for: key), + action: { + handleKeyTap(key) + } + ) + } + } + } + } + } + + @ViewBuilder + private func keyboardKeyView( + _ key: Key, + height: CGFloat? = nil, + isActive: Bool = false, + customAction: (() -> Void)? = nil + ) -> some View { + KeyView( + key: key, + height: height ?? keyHeight, + keyboardType: keyboardType, + mode: mode, + usesDirectionPadBaseStyle: mode == .picker, + isActive: isActive, + highlightStyle: highlightStyle(for: key), + action: { + if let customAction = customAction { + customAction() + } else { + handleKeyTap(key) + } + } + ) + } + + private func handleKeyTap(_ key: Key) { + switch key.role { + case .fn: + fnMode.toggle() + case .keyboardSwitch: + keyboardType = keyboardType == .win ? .mac : .win + case .normal: + if mode == .picker, let family = selectionFamily(for: key) { + handleDirectionKeyTap(key, family: family) + } else if mode == .picker || isTypingToggleKey(key) { + toggleRegularKeyHighlight(key) + } + } + } + + private func isTypingToggleKey(_ key: Key) -> Bool { + let normalizedLabel = key.label.lowercased() + return normalizedLabel == "tab" + || normalizedLabel == "shift" + || normalizedLabel == "ctrl" + || normalizedLabel == "alt" + || normalizedLabel == "control" + || normalizedLabel == "opt" + } + + private func toggleRegularKeyHighlight(_ key: Key) { + let keyID = regularHighlightID(for: key) + + if highlightedKeyIDs.contains(keyID) { + highlightedKeyIDs.remove(keyID) + if let cmd = key.cmdString(for: keyboardType), !cmd.isEmpty { + onCommandDeselected?(cmd) + } + if VirtualKeyboardView.selectedCmd == key.cmdString(for: keyboardType) { + VirtualKeyboardView.selectedCmd = "" + } + } else { + let cmd = key.cmdString(for: keyboardType) ?? "" + if !cmd.isEmpty, canSelectCommand?(cmd) == false { + return + } + highlightedKeyIDs.insert(keyID) + VirtualKeyboardView.tappedKeyLabels = [key.label] + VirtualKeyboardView.selectedCmd = cmd + VirtualKeyboardView.lastSelectionDisplayText = poolDisplayText(for: key) + VirtualKeyboardView.lastSelectionUsesMacLayout = keyboardType == .mac + VirtualKeyboardView.lastSelectionFromMouseWidgets = isMouseWidgetKey(key) + VirtualKeyboardView.lastSelectionUsesFnCommandDisplay = usesFnCommandDisplay(for: key) + if !VirtualKeyboardView.selectedCmd.isEmpty { + onCommandSelected?(VirtualKeyboardView.selectedCmd) + } + } + } + + private func handleDirectionKeyTap(_ key: Key, family: KeyboardSelectionFamily) { + if padSelections.contains(family) { + padSelections.remove(family) + onCommandDeselected?(padCommand(for: family)) + if VirtualKeyboardView.selectedCmd == padCommand(for: family) { + VirtualKeyboardView.selectedCmd = "" + } + return + } + + if directionSingleKeyLabels.contains(key.label) { + directionSingleKeyLabels.remove(key.label) + if let cmd = key.cmdString(for: keyboardType), !cmd.isEmpty { + onCommandDeselected?(cmd) + } + if VirtualKeyboardView.selectedCmd == key.cmdString(for: keyboardType) { + VirtualKeyboardView.selectedCmd = "" + } + return + } + + pendingSelectionFamily = family + pendingKeyboardOptions = keyboardOptions(for: key, family: family) + VirtualKeyboardView.tappedKeyLabels = [key.label] + showKeyboardWidgetPicker = true + } + + private func keyboardOptions(for key: Key, family: KeyboardSelectionFamily) -> [KeyboardWidgetOption] { + let singleCommand = key.cmdString(for: keyboardType) ?? key.label + let singleDescription = singleKeyDescription(for: key, family: family) + + switch family { + case .wasd: + return [ + KeyboardWidgetOption( + command: singleCommand, + description: singleDescription, + highlightedLabels: [key.label], + family: .wasd, + selectionState: .single(key.label) + ), + KeyboardWidgetOption( + command: "WASDPAD", + description: SwiftLocalizationHelper.localizedString(forKey: "WASD direction pad"), + highlightedLabels: ["W", "A", "S", "D"], + family: .wasd, + selectionState: .pad + ) + ] + case .arrow: + return [ + KeyboardWidgetOption( + command: singleCommand, + description: singleDescription, + highlightedLabels: [key.label], + family: .arrow, + selectionState: .single(key.label) + ), + KeyboardWidgetOption( + command: "ARROWPAD", + description: SwiftLocalizationHelper.localizedString(forKey: "Arrow direction pad"), + highlightedLabels: ["↑", "↓", "←", "→"], + family: .arrow, + selectionState: .pad + ) + ] + } + } + + private func singleKeyDescription(for key: Key, family: KeyboardSelectionFamily) -> String { + switch family { + case .wasd: + return key.label + case .arrow: + switch key.label { + case "↑": return SwiftLocalizationHelper.localizedString(forKey: "Up Arrow") + case "↓": return SwiftLocalizationHelper.localizedString(forKey: "Down Arrow") + case "←": return SwiftLocalizationHelper.localizedString(forKey: "Left Arrow") + case "→": return SwiftLocalizationHelper.localizedString(forKey: "Right Arrow") + default: return key.label + } + } + } + + private func poolDisplayText(for key: Key) -> String { + key.label + } + + private func poolDisplayText(for option: KeyboardWidgetOption) -> String { + switch option.family { + case .wasd: + if option.selectionState == .pad { + return "WASD" + } + case .arrow: + if option.selectionState == .pad { + return "Arrows" + } + } + return option.highlightedLabels.first ?? option.command + } + + private func isMouseWidgetKey(_ key: Key) -> Bool { + let mouseCommands: Set = [ + "MLEFT", "MRIGHT", "MMIDDLE", "MX1", "MX2", + "WHEELUP", "WHEELDOWN", "WHEEL", "MOUSEPAD", "TRACKBALL" + ] + if let command = key.cmdString(for: keyboardType) { + return mouseCommands.contains(command) + } + return false + } + + private func usesFnCommandDisplay(for key: Key) -> Bool { + guard fnMode else { return false } + let fnRowLabels: Set = [ + "F1", "F2", "F3", "F4", "F5", "F6", + "F7", "F8", "F9", "F10", "F11", "F12", + "App1", "App2" + ] + return fnRowLabels.contains(key.label) + } + + private func selectKeyboardWidget(_ option: KeyboardWidgetOption) { + if canSelectCommand?(option.command) == false { + showKeyboardWidgetPicker = false + return + } + applySelection(option) + VirtualKeyboardView.tappedKeyLabels = option.highlightedLabels + VirtualKeyboardView.selectedCmd = option.command + VirtualKeyboardView.lastSelectionDisplayText = poolDisplayText(for: option) + VirtualKeyboardView.lastSelectionUsesMacLayout = keyboardType == .mac + VirtualKeyboardView.lastSelectionFromMouseWidgets = false + VirtualKeyboardView.lastSelectionUsesFnCommandDisplay = false + onCommandSelected?(option.command) + showKeyboardWidgetPicker = false + } + + private func resetCurrentKeyboardWidgetSelection() { + if let family = pendingSelectionFamily { + notifyDeselection(for: family) + clearSelection(for: family) + } + VirtualKeyboardView.tappedKeyLabels = [] + VirtualKeyboardView.selectedCmd = "" + showKeyboardWidgetPicker = false + } + + private func clearSelection(for family: KeyboardSelectionFamily) { + directionSingleKeyLabels.subtract(labels(for: family)) + padSelections.remove(family) + } + + private func selectionFamily(for key: Key) -> KeyboardSelectionFamily? { + if ["W", "A", "S", "D"].contains(key.label) { + return .wasd + } + + if ["↑", "↓", "←", "→"].contains(key.label) { + return .arrow + } + + return nil + } + + private func labels(for family: KeyboardSelectionFamily) -> [String] { + switch family { + case .wasd: + return ["W", "A", "S", "D"] + case .arrow: + return ["↑", "↓", "←", "→"] + } + } + + private func padCommand(for family: KeyboardSelectionFamily) -> String { + switch family { + case .wasd: + return "WASDPAD" + case .arrow: + return "ARROWPAD" + } + } + + private func applySelection(_ option: KeyboardWidgetOption) { + switch option.selectionState { + case .single(let label): + padSelections.remove(option.family) + directionSingleKeyLabels.insert(label) + case .pad: + directionSingleKeyLabels.subtract(labels(for: option.family)) + padSelections.insert(option.family) + case .none: + clearSelection(for: option.family) + } + } + + private func highlightStyle(for key: Key) -> KeyHighlightStyle { + if mode == .picker, let family = selectionFamily(for: key), padSelections.contains(family) { + return .pad + } + + if highlightedKeyIDs.contains(regularHighlightID(for: key)) + || (mode == .picker && directionSingleKeyLabels.contains(key.label)) { + return isPadLikeControl(key) ? .pad : .single + } + + return .none + } + + private func regularHighlightID(for key: Key) -> String { + if let identity = key.identity { + return identity + } + let cmd = key.cmdString(for: keyboardType) ?? "NULL" + let code = key.keyCode(for: keyboardType) ?? 0xFF + return "\(key.label)|\(cmd)|\(code)" + } + + private func isPadLikeControl(_ key: Key) -> Bool { + let padCommands: Set = ["WHEEL", "MOUSEPAD", "TRACKBALL"] + if let cmd = key.cmdString(for: keyboardType) { + return padCommands.contains(cmd) + } + return false + } + + + var mainRows: [Row] { + [ + Row(keys: [ + Key(label: "Esc", width: unit, winCmdString: "ESC", macCmdString: "ESCAPEMAC", winKeyCode: 0x1B, macKeyCode: 0x35), + + Key(label: "F1", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? "speaker.slash.fill" : "sun.min") : nil, winCmdString: fnMode ? "VOLMUTE" : "F1", macCmdString: fnMode ? "FUNCTION-F1MAC-50MS" : "F1MAC", winKeyCode: fnMode ? 0xAD : 0x70, macKeyCode: 0x7A), + Key(label: "F2", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? "speaker.wave.1.fill" : "sun.max") : nil, winCmdString: fnMode ? "VOLDOWN" : "F2", macCmdString: fnMode ? "FUNCTION-F2MAC-50MS" : "F2MAC", winKeyCode: fnMode ? 0xAE : 0x71, macKeyCode: 0x78), + Key(label: "F3", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? "speaker.wave.3.fill" : "rectangle.3.offgrid") : nil, winCmdString: fnMode ? "VOLUP" : "F3", macCmdString: fnMode ? "FUNCTION-F3MAC-50MS" : "F3MAC", winKeyCode: fnMode ? 0xAF : 0x72, macKeyCode: 0x63), + Key(label: "F4", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? "music.note" : "magnifyingglass") : nil, winCmdString: fnMode ? "MEDIA_SELECT" : "F4", macCmdString: fnMode ? "FUNCTION-F4MAC-50MS" : "F4MAC", winKeyCode: fnMode ? 0xB5 : 0x73, macKeyCode: 0x76), + + Key(label: "F5", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? "backward.fill" : "mic") : nil, winCmdString: fnMode ? "MEDIAPREV" : "F5", macCmdString: fnMode ? "FUNCTION-F5MAC-50MS" : "F5MAC", winKeyCode: fnMode ? 0xB1 : 0x74, macKeyCode: 0x60), + Key(label: "F6", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? "playpause.fill" : "moon") : nil, winCmdString: fnMode ? "PLAYPAUSE" : "F6", macCmdString: fnMode ? "FUNCTION-F6MAC-50MS" : "F6MAC", winKeyCode: fnMode ? 0xB3 : 0x75, macKeyCode: 0x61), + Key(label: "F7", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? "forward.fill" : "backward.fill") : nil, winCmdString: fnMode ? "MEDIANEXT" : "F7", macCmdString: fnMode ? "FUNCTION-F7MAC-50MS" : "F7MAC", winKeyCode: fnMode ? 0xB0 : 0x76, macKeyCode: 0x62), + Key(label: "F8", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? "stop.fill" : "playpause.fill") : nil, winCmdString: fnMode ? "MEDIASTOP" : "F8", macCmdString: fnMode ? "FUNCTION-F8MAC-50MS" : "F8MAC", winKeyCode: fnMode ? 0xB2 : 0x77, macKeyCode: 0x64), + + Key(label: "F9", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? "envelope.fill" : "forward.fill") : nil, winCmdString: fnMode ? "LAUNCHMAIL" : "F9", macCmdString: fnMode ? "FUNCTION-F9MAC-50MS" : "F9MAC", winKeyCode: fnMode ? 0xB4 : 0x78, macKeyCode: 0x65), + Key(label: fnMode ? "App1" : "F10", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? nil : "speaker.slash") : nil, winCmdString: fnMode ? "LAUNCHAPP1" : "F10", macCmdString: fnMode ? "FUNCTION-F10MAC-50MS" : "F10MAC", winKeyCode: fnMode ? 0xB6 : 0x79, macKeyCode: 0x6D), + Key(label: fnMode ? "App2" : "F11", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? nil : "speaker.wave.1") : nil, winCmdString: fnMode ? "LAUNCHAPP2" : "F11", macCmdString: fnMode ? "FUNCTION-F11MAC-50MS" : "F11MAC", winKeyCode: fnMode ? 0xB7 : 0x7A, macKeyCode: 0x67), + Key(label: "F12", width: unit, sfSymbolName: fnMode ? (keyboardType == .win ? nil : "speaker.wave.3") : nil, winCmdString: "F12", macCmdString: fnMode ? "FUNCTION-F12MAC-50MS" : "F12MAC", winKeyCode: 0x7B, macKeyCode: 0x6F), + + Key(label: "Null", width: unit, winCmdString: "NULL", macCmdString: "NULL", winKeyCode: 0xFF, macKeyCode: 0xFF), + ]), + Row(keys: [ + Key(label: "` ~", width: unit, winCmdString: "GRAVEACCENT", macCmdString: "GRAVEACCENTMAC", winKeyCode: 0xC0, macKeyCode: 0x32), + Key(label: "1 !", width: unit, winCmdString: "1", macCmdString: "1MAC", winKeyCode: 0x31, macKeyCode: 0x53), + Key(label: "2 @", width: unit, winCmdString: "2", macCmdString: "2MAC", winKeyCode: 0x32, macKeyCode: 0x54), + Key(label: "3 #", width: unit, winCmdString: "3", macCmdString: "3MAC", winKeyCode: 0x33, macKeyCode: 0x55), + Key(label: "4 $", width: unit, winCmdString: "4", macCmdString: "4MAC", winKeyCode: 0x34, macKeyCode: 0x56), + Key(label: "5 %", width: unit, winCmdString: "5", macCmdString: "5MAC", winKeyCode: 0x35, macKeyCode: 0x57), + Key(label: "6 ^", width: unit, winCmdString: "6", macCmdString: "6MAC", winKeyCode: 0x36, macKeyCode: 0x58), + Key(label: "7 &", width: unit, winCmdString: "7", macCmdString: "7MAC", winKeyCode: 0x37, macKeyCode: 0x59), + Key(label: "8 *", width: unit, winCmdString: "8", macCmdString: "8MAC", winKeyCode: 0x38, macKeyCode: 0x5A), + Key(label: "9 (", width: unit, winCmdString: "9", macCmdString: "9MAC", winKeyCode: 0x39, macKeyCode: 0x5B), + Key(label: "0 )", width: unit, winCmdString: "0", macCmdString: "0MAC", winKeyCode: 0x30, macKeyCode: 0x52), + Key(label: "- _", width: unit, winCmdString: "MINUS", macCmdString: "MINUSMAC", winKeyCode: 0xBD, macKeyCode: 0x1B), + Key(label: "= +", width: unit, winCmdString: "EQUALS", macCmdString: "EQUALSMAC", winKeyCode: 0xBB, macKeyCode: 0x18), + Key(label: keyboardType == .win ? "Backspace" : "Delete", width: unit * 2, winCmdString: "BACKSPACE", macCmdString: "BACKSPACEMAC", winKeyCode: 0x08, macKeyCode: 0x33) + ]), + Row(keys: [ + Key(label: "Tab", width: unit * 1.5, winCmdString: "TAB", macCmdString: "TABMAC", winKeyCode: 0x09, macKeyCode: 0x30), + Key(label: "Q", width: unit, winCmdString: "Q", macCmdString: "QMAC", winKeyCode: 0x51, macKeyCode: 0x0C), + Key(label: "W", visualStyle: .directionPad, width: unit, winCmdString: "W", macCmdString: "WMAC", winKeyCode: 0x57, macKeyCode: 0x0D), + Key(label: "E", width: unit, winCmdString: "E", macCmdString: "EMAC", winKeyCode: 0x45, macKeyCode: 0x0E), + Key(label: "R", width: unit, winCmdString: "R", macCmdString: "RMAC", winKeyCode: 0x52, macKeyCode: 0x0F), + Key(label: "T", width: unit, winCmdString: "T", macCmdString: "TMAC", winKeyCode: 0x54, macKeyCode: 0x11), + Key(label: "Y", width: unit, winCmdString: "Y", macCmdString: "YMAC", winKeyCode: 0x59, macKeyCode: 0x10), + Key(label: "U", width: unit, winCmdString: "U", macCmdString: "UMAC", winKeyCode: 0x55, macKeyCode: 0x20), + Key(label: "I", width: unit, winCmdString: "I", macCmdString: "IMAC", winKeyCode: 0x49, macKeyCode: 0x22), + Key(label: "O", width: unit, winCmdString: "O", macCmdString: "OMAC", winKeyCode: 0x4F, macKeyCode: 0x1F), + Key(label: "P", width: unit, winCmdString: "P", macCmdString: "PMAC", winKeyCode: 0x50, macKeyCode: 0x23), + Key(label: "[ {", width: unit, winCmdString: "OPENBRACKET", macCmdString: "OPENBRACKETMAC", winKeyCode: 0xDB, macKeyCode: 0x21), + Key(label: "] }", width: unit, winCmdString: "CLOSEBRACKET", macCmdString: "CLOSEBRACKETMAC", winKeyCode: 0xDD, macKeyCode: 0x1E), + Key(label: "\\ |", width: unit * 1.5, winCmdString: "BACKSLASH", macCmdString: "BACKSLASHMAC", winKeyCode: 0xDC, macKeyCode: 0x2A) + ]), + Row(keys: [ + Key(label: "Caps", width: unit * 1.75, winCmdString: "CAPSLOCK", macCmdString: "CAPSLOCKMAC", winKeyCode: 0x14, macKeyCode: 0x39), + Key(label: "A", visualStyle: .directionPad, width: unit, winCmdString: "A", macCmdString: "AMAC", winKeyCode: 0x41, macKeyCode: 0x00), + Key(label: "S", visualStyle: .directionPad, width: unit, winCmdString: "S", macCmdString: "SMAC", winKeyCode: 0x53, macKeyCode: 0x01), + Key(label: "D", visualStyle: .directionPad, width: unit, winCmdString: "D", macCmdString: "DMAC", winKeyCode: 0x44, macKeyCode: 0x02), + Key(label: "F", width: unit, winCmdString: "F", macCmdString: "FMAC", winKeyCode: 0x46, macKeyCode: 0x03), + Key(label: "G", width: unit, winCmdString: "G", macCmdString: "GMAC", winKeyCode: 0x47, macKeyCode: 0x05), + Key(label: "H", width: unit, winCmdString: "H", macCmdString: "HMAC", winKeyCode: 0x48, macKeyCode: 0x04), + Key(label: "J", width: unit, winCmdString: "J", macCmdString: "JMAC", winKeyCode: 0x4A, macKeyCode: 0x26), + Key(label: "K", width: unit, winCmdString: "K", macCmdString: "KMAC", winKeyCode: 0x4B, macKeyCode: 0x28), + Key(label: "L", width: unit, winCmdString: "L", macCmdString: "LMAC", winKeyCode: 0x4C, macKeyCode: 0x25), + Key(label: "; :", width: unit, winCmdString: "SEMICOLON", macCmdString: "SEMICOLONMAC", winKeyCode: 0xBA, macKeyCode: 0x29), + Key(label: "' \"", width: unit, winCmdString: "SINGLEQUOTE", macCmdString: "SINGLEQUOTEMAC", winKeyCode: 0xDE, macKeyCode: 0x27), + Key(label: keyboardType == .win ? "Enter" : "Return", identity: "main-enter", width: unit * 2.25, winCmdString: "ENTER", macCmdString: "RETURNMAC", winKeyCode: 0x0D, macKeyCode: 0x24) + ]), + Row(keys: [ + Key(label: "Shift", identity: "left-shift", width: unit * 2.25, winCmdString: "SHIFT", macCmdString: "SHIFTMAC", winKeyCode: 0x10, macKeyCode: 0x38), + Key(label: "Z", width: unit, winCmdString: "Z", macCmdString: "ZMAC", winKeyCode: 0x5A, macKeyCode: 0x06), + Key(label: "X", width: unit, winCmdString: "X", macCmdString: "XMAC", winKeyCode: 0x58, macKeyCode: 0x07), + Key(label: "C", width: unit, winCmdString: "C", macCmdString: "CMAC", winKeyCode: 0x43, macKeyCode: 0x08), + Key(label: "V", width: unit, winCmdString: "V", macCmdString: "VMAC", winKeyCode: 0x56, macKeyCode: 0x09), + Key(label: "B", width: unit, winCmdString: "B", macCmdString: "BMAC", winKeyCode: 0x42, macKeyCode: 0x0B), + Key(label: "N", width: unit, winCmdString: "N", macCmdString: "NMAC", winKeyCode: 0x4E, macKeyCode: 0x2D), + Key(label: "M", width: unit, winCmdString: "M", macCmdString: "MMAC", winKeyCode: 0x4D, macKeyCode: 0x2E), + Key(label: ", <", width: unit, winCmdString: "COMMA", macCmdString: "COMMAMAC", winKeyCode: 0xBC, macKeyCode: 0x2B), + Key(label: ". >", width: unit, winCmdString: "PERIOD", macCmdString: "PERIODMAC", winKeyCode: 0xBE, macKeyCode: 0x2F), + Key(label: "/ ?", width: unit, winCmdString: "FORWARDSLASH", macCmdString: "FORWARDSLASHMAC", winKeyCode: 0xBF, macKeyCode: 0x2C), + Key(label: "Shift", identity: "right-shift", width: unit * 2.75, winCmdString: "RSHIFT", macCmdString: "SHIFTMAC", winKeyCode: 0xA1, macKeyCode: 0x38) + ]), + Row(keys: keyboardType == .win ? [ + Key(label: keyboardType == .win ? "Win" : "Mac", role: .keyboardSwitch, width: unit * 1.25, winCmdString: "NULL", macCmdString: "NULL", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: "Fn", role: .fn, width: unit * 1.25, winCmdString: "FUNCTION", macCmdString: "FUNCTION", winKeyCode: nil, macKeyCode: 0x3F), + + Key(label: "Ctrl", width: unit * 1.25, winCmdString: "CTRL", macCmdString: nil, winKeyCode: 0x11, macKeyCode: nil), + Key(label: "Win", width: unit * 1.25, sfSymbolName: "square.grid.2x2.fill", winCmdString: "WIN", macCmdString: nil, winKeyCode: 0x5B, macKeyCode: nil), + Key(label: "Alt", width: unit * 1.25, winCmdString: "ALT", macCmdString: nil, winKeyCode: 0x12, macKeyCode: nil), + Key(label: "Space", width: unit * 6, winCmdString: "SPACE", macCmdString: nil, winKeyCode: 0x20, macKeyCode: nil), + Key(label: "Alt", width: unit * 1.25, winCmdString: "RALT", macCmdString: nil, winKeyCode: 0xA5, macKeyCode: nil), + Key(label: "Apps", width: unit * 1.25, sfSymbolName: "filemenu.and.selection", winCmdString: "APPS", macCmdString: nil, winKeyCode: 0x5D, macKeyCode: nil), + Key(label: "Ctrl", width: unit * 1.25, winCmdString: "RCTRL", macCmdString: nil, winKeyCode: 0xA3, macKeyCode: nil) + ]:[ + Key(label: keyboardType == .win ? "Win" : "Mac", role: .keyboardSwitch, width: unit * 1.25, winCmdString: "NULL", macCmdString: "NULL", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: "Fn", role: .fn, width: unit * 1.25, winCmdString: "FUNCTION", macCmdString: "FUNCTION", winKeyCode: nil, macKeyCode: 0x3F), + + Key(label: "Control", width: unit * 1.25, sfSymbolName: "control", winCmdString: nil, macCmdString: "CONTROL", winKeyCode: nil, macKeyCode: 0x3B), + Key(label: "Opt", width: unit * 1.25, sfSymbolName: "option", winCmdString: nil, macCmdString: "OPT", winKeyCode: nil, macKeyCode: 0x3A), + Key(label: "Cmd", width: unit * 1.25, sfSymbolName: "command", winCmdString: nil, macCmdString: "CMD", winKeyCode: nil, macKeyCode: 0x37), + Key(label: "Space", width: unit * 6, winCmdString: nil, macCmdString: "SPACEMAC", winKeyCode: nil, macKeyCode: 0x31), + Key(label: "Cmd", width: unit * 1.25, sfSymbolName: "command", winCmdString: nil, macCmdString: "RIGHTCMD", winKeyCode: nil, macKeyCode: 0x36), + Key(label: "Opt", width: unit * 1.25, sfSymbolName: "option", winCmdString: nil, macCmdString: "RIGHTOPT", winKeyCode: nil, macKeyCode: 0x3D), + Key(label: "Control", width: unit * 1.25, sfSymbolName: "control", winCmdString: nil, macCmdString: "RIGHTCONTROL", winKeyCode: nil, macKeyCode: 0x3E), + ]) + ] + } + + // MARK: - 导航区 + var navBlock: some View { + VStack(spacing: spacing) { + VStack(spacing: spacing) { + HStack(spacing: spacing) { + keyboardKeyView(Key(label: keyboardType == .win ? "PrScr" : "F13", width: unit, winCmdString: "PRSCR", macCmdString: "F13MAC", winKeyCode: 0x2C, macKeyCode: 0x69)) + keyboardKeyView(Key(label: keyboardType == .win ? "ScrLk" : "F14", width: unit, winCmdString: "SCRLCK", macCmdString: "F14MAC", winKeyCode: 0x91, macKeyCode: 0x6B)) + keyboardKeyView(Key(label: keyboardType == .win ? "Pause" : "F15", width: unit, winCmdString: "PAUSE", macCmdString: "F15MAC", winKeyCode: 0x13, macKeyCode: 0x71)) + } + HStack(spacing: spacing) { + keyboardKeyView(Key(label: "Ins", width: unit, sfSymbolName:(keyboardType == .win ? nil : "filemenu.and.selection"), winCmdString: "INSERT", macCmdString: "CONTEXTMAC", winKeyCode: 0x2D, macKeyCode: 0x72)) + keyboardKeyView(Key(label: "Home", width: unit, winCmdString: "HOME", macCmdString: "HOMEMAC", winKeyCode: 0x24, macKeyCode: 0x73)) + keyboardKeyView(Key(label: "PgUp", width: unit, winCmdString: "PGUP", macCmdString: "PGUPMAC", winKeyCode: 0x21, macKeyCode: 0x74)) + } + HStack(spacing: spacing) { + keyboardKeyView(Key(label: "Del", width: unit, sfSymbolName:(keyboardType == .win ? nil : "delete.right"), winCmdString: "DEL", macCmdString: "DELMAC", winKeyCode: 0x2E, macKeyCode: 0x75)) + keyboardKeyView(Key(label: "End", width: unit, winCmdString: "END", macCmdString: "ENDMAC", winKeyCode: 0x23, macKeyCode: 0x77)) + keyboardKeyView(Key(label: "PgDn", width: unit, winCmdString: "PGDN", macCmdString: "PGDNMAC", winKeyCode: 0x22, macKeyCode: 0x79)) + } + } + Spacer() + VStack(spacing: spacing) { + HStack(spacing: spacing) { + keyboardKeyView(Key(label: "←", visualStyle: .directionPad, width: unit, winCmdString: "LEFTARR", macCmdString: "LEFTARRMAC", winKeyCode: 0x25, macKeyCode: 0x7B)).opacity(0) + keyboardKeyView(Key(label: "↑", visualStyle: .directionPad, width: unit, winCmdString: "UPARR", macCmdString: "UPARRMAC", winKeyCode: 0x26, macKeyCode: 0x7E)) + keyboardKeyView(Key(label: "→", visualStyle: .directionPad, width: unit, winCmdString: "RIGHTARR", macCmdString: "RIGHTARRMAC", winKeyCode: 0x27, macKeyCode: 0x7C)).opacity(0) + } + HStack(spacing: spacing) { + keyboardKeyView(Key(label: "←", visualStyle: .directionPad, width: unit, winCmdString: "LEFTARR", macCmdString: "LEFTARRMAC", winKeyCode: 0x25, macKeyCode: 0x7B)) + keyboardKeyView(Key(label: "↓", visualStyle: .directionPad, width: unit, winCmdString: "DOWNARR", macCmdString: "DOWNARRMAC", winKeyCode: 0x28, macKeyCode: 0x7D)) + keyboardKeyView(Key(label: "→", visualStyle: .directionPad, width: unit, winCmdString: "RIGHTARR", macCmdString: "RIGHTARRMAC", winKeyCode: 0x27, macKeyCode: 0x7C)) + } + } + } + .frame(height: totalHeight, alignment: .top) + } + + // MARK: - 小键盘 + // MARK: - 小键盘 / 鼠标控件区 + var numpadBlock: some View { + VStack(spacing: spacing) { + if mode == .picker { + HStack(spacing: spacing) { + KeyView( + key: Key( + label: showsMouseWidgets + ? SwiftLocalizationHelper.localizedString(forKey: "Mouse Widgets") + : SwiftLocalizationHelper.localizedString(forKey: "Numpad"), + width: unit * 2 + spacing, + sfSymbolName: showsMouseWidgets + ? "computermouse.fill" + : "circle.grid.3x3", + winCmdString: "NULL", + macCmdString: "NULL", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ), + height: keyHeight, + keyboardType: keyboardType, + mode: mode + ) + + KeyView( + key: Key( + label: showsMouseWidgets + ? SwiftLocalizationHelper.localizedString(forKey: "numpad") + : SwiftLocalizationHelper.localizedString(forKey: "mouse widgets"), + width: unit * 2 + spacing, + winCmdString: "NULL", + macCmdString: "NULL", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ), + height: keyHeight, + keyboardType: keyboardType, + mode: mode, + isActive: showsMouseWidgets, + action: { + showsMouseWidgets.toggle() + } + ) + } + } + + if mode == .picker { + if showsMouseWidgets { + mouseWidgetsBlock + } else { + numpadKeysBlock + } + } else { + Spacer() + numpadKeysBlock + } + } + .frame(height: totalHeight, alignment: .top) + } + + var numpadKeysBlock: some View { + VStack(spacing: spacing) { + HStack(spacing: spacing) { + keyboardKeyView(Key(label: keyboardType == .win ? "Num" : "Clear", width: unit, winCmdString: "NUMLCK", macCmdString: "NUMPADCLEARMAC", winKeyCode: 0x90, macKeyCode: 0x47)) + keyboardKeyView(Key(label: "/", width: unit, winCmdString: "DIVIDE", macCmdString: "NUMPADDIVIDEMAC", winKeyCode: 0x6F, macKeyCode: 0x4B)) + keyboardKeyView(Key(label: "*", width: unit, winCmdString: "MULTIPLY", macCmdString: "NUMPADMULTIPLYMAC", winKeyCode: 0x6A, macKeyCode: 0x43)) + keyboardKeyView(Key(label: "-", width: unit, winCmdString: "SUBTRACT", macCmdString: "NUMPADSUBTRACTMAC", winKeyCode: 0x6D, macKeyCode: 0x4E)) + } + + HStack(alignment: .top, spacing: spacing) { + VStack(spacing: spacing) { + HStack(spacing: spacing) { + keyboardKeyView(Key(label: "7", width: unit, winCmdString: "NUMPAD7", macCmdString: "NUMPAD7MAC", winKeyCode: 0x67, macKeyCode: 0x56)) + keyboardKeyView(Key(label: "8", width: unit, winCmdString: "NUMPAD8", macCmdString: "NUMPAD8MAC", winKeyCode: 0x68, macKeyCode: 0x57)) + keyboardKeyView(Key(label: "9", width: unit, winCmdString: "NUMPAD9", macCmdString: "NUMPAD9MAC", winKeyCode: 0x69, macKeyCode: 0x58)) + } + + HStack(spacing: spacing) { + keyboardKeyView(Key(label: "4", width: unit, winCmdString: "NUMPAD4", macCmdString: "NUMPAD4MAC", winKeyCode: 0x64, macKeyCode: 0x53)) + keyboardKeyView(Key(label: "5", width: unit, winCmdString: "NUMPAD5", macCmdString: "NUMPAD5MAC", winKeyCode: 0x65, macKeyCode: 0x54)) + keyboardKeyView(Key(label: "6", width: unit, winCmdString: "NUMPAD6", macCmdString: "NUMPAD6MAC", winKeyCode: 0x66, macKeyCode: 0x55)) + } + + HStack(spacing: spacing) { + keyboardKeyView(Key(label: "1", width: unit, winCmdString: "NUMPAD1", macCmdString: "NUMPAD1MAC", winKeyCode: 0x61, macKeyCode: 0x50)) + keyboardKeyView(Key(label: "2", width: unit, winCmdString: "NUMPAD2", macCmdString: "NUMPAD2MAC", winKeyCode: 0x62, macKeyCode: 0x51)) + keyboardKeyView(Key(label: "3", width: unit, winCmdString: "NUMPAD3", macCmdString: "NUMPAD3MAC", winKeyCode: 0x63, macKeyCode: 0x52)) + } + + HStack(spacing: spacing) { + keyboardKeyView(Key(label: "0", width: unit * 2 + spacing, winCmdString: "NUMPAD0", macCmdString: "NUMPAD0MAC", winKeyCode: 0x60, macKeyCode: 0x4F)) + keyboardKeyView(Key(label: ".", width: unit, winCmdString: "DECIMAL", macCmdString: "NUMPADDECIMALMAC", winKeyCode: 0x6E, macKeyCode: 0x41)) + } + } + + VStack(spacing: spacing) { + keyboardKeyView( + Key(label: "+", width: unit, winCmdString: "ADD", macCmdString: "NUMPADADDMAC", winKeyCode: 0x6B, macKeyCode: 0x45), + height: keyHeight * (keyboardType == .win ? 2 : 1) + (keyboardType == .win ? spacing : 0) + ) + keyboardType == .win ? nil : keyboardKeyView(Key(label: "=", width: unit, winCmdString: nil, macCmdString: "NUMPADEQUALMAC", winKeyCode: nil, macKeyCode: 0x51)) + keyboardKeyView( + Key(label: "Enter", identity: "numpad-enter", width: unit, winCmdString: "ENTER", macCmdString: "ENTERMAC", winKeyCode: 0x0D, macKeyCode: 0x4C), + height: keyHeight * 2 + spacing + ) + } + } + } + } + + var mouseWidgetsBlock: some View { + VStack(spacing: spacing) { + HStack(spacing: spacing) { + KeyView( + key: Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Left Button"), + width: unit * 2 + spacing, + winCmdString: "MLEFT", + macCmdString: "MLEFT", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ), + height: keyHeight, + keyboardType: keyboardType, + mode: mode, + highlightStyle: highlightStyle(for: Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Left Button"), + width: unit * 2 + spacing, + winCmdString: "MLEFT", + macCmdString: "MLEFT", + winKeyCode: 0xFF, + macKeyCode: 0xFF + )), + action: { + handleKeyTap(Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Left Button"), + width: unit * 2 + spacing, + winCmdString: "MLEFT", + macCmdString: "MLEFT", + winKeyCode: 0xFF, + macKeyCode: 0xFF + )) + } + ) + + KeyView( + key: Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Right Button"), + width: unit * 2 + spacing, + winCmdString: "MRIGHT", + macCmdString: "MRIGHT", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ), + height: keyHeight, + keyboardType: keyboardType, + mode: mode, + highlightStyle: highlightStyle(for: Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Right Button"), + width: unit * 2 + spacing, + winCmdString: "MRIGHT", + macCmdString: "MRIGHT", + winKeyCode: 0xFF, + macKeyCode: 0xFF + )), + action: { + handleKeyTap(Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Right Button"), + width: unit * 2 + spacing, + winCmdString: "MRIGHT", + macCmdString: "MRIGHT", + winKeyCode: 0xFF, + macKeyCode: 0xFF + )) + } + ) + } + + HStack(spacing: spacing) { + keyboardKeyView( + Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Middle Button"), + width: unit * 2 + spacing, + winCmdString: "MMIDDLE", + macCmdString: "MMIDDLE", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ) + ) + + keyboardKeyView( + Key( + label: SwiftLocalizationHelper.localizedString(forKey: "X1"), + width: unit, + winCmdString: "MX1", + macCmdString: "MX1", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ) + ) + + keyboardKeyView( + Key( + label: SwiftLocalizationHelper.localizedString(forKey: "X2"), + width: unit, + winCmdString: "MX2", + macCmdString: "MX2", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ) + ) + } + + HStack(spacing: spacing) { + keyboardKeyView( + Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Wheel\nUp"), + width: unit, + winCmdString: "WHEELDOWN", + macCmdString: "WHEELDOWN", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ) + ) + + keyboardKeyView( + Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Wheel\nDown"), + width: unit, + winCmdString: "WHEELUP", + macCmdString: "WHEELUP", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ) + ) + + keyboardKeyView( + Key( + label: SwiftLocalizationHelper.localizedString(forKey: "WheelPad"), + width: unit * 2 + spacing, + winCmdString: "WHEEL", + macCmdString: "WHEEL", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ) + ) + } + + HStack(spacing: spacing) { + keyboardKeyView( + Key( + label: SwiftLocalizationHelper.localizedString(forKey: "MousePad"), + width: unit * 2 + spacing, + winCmdString: "MOUSEPAD", + macCmdString: "MOUSEPAD", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ), + height: keyHeight * 2 + spacing + ) + + keyboardKeyView( + Key( + label: SwiftLocalizationHelper.localizedString(forKey: "Trackball"), + width: unit * 2 + spacing, + winCmdString: "TRACKBALL", + macCmdString: "TRACKBALL", + winKeyCode: 0xFF, + macKeyCode: 0xFF + ), + height: keyHeight * 2 + spacing + ) + } + } + } + var totalHeight: CGFloat { + CGFloat(mainRows.count) * keyHeight + CGFloat(mainRows.count - 1) * spacing + } + + private var pickerSectionSpacing: CGFloat { + 18 + } + + private var mainBlockWidth: CGFloat { + mainRows + .map { row in + row.keys.reduce(CGFloat(0)) { partialResult, key in + partialResult + key.width + } + CGFloat(max(row.keys.count - 1, 0)) * spacing + } + .max() ?? 0 + } + + private var navBlockWidth: CGFloat { + unit * 3 + spacing * 2 + } + + private var numpadBlockWidth: CGFloat { + unit * 4 + spacing * 3 + } + + private var equalBottomRowGap: CGFloat { + max((mainBlockWidth - navBlockWidth - numpadBlockWidth) / 3, 0) + } + + private var pickerContentWidth: CGFloat { + mainBlockWidth + } + + private var pickerContentHeight: CGFloat { + totalHeight * 2 + pickerSectionSpacing + } + + private func resetAllSelections() { + highlightedKeyIDs.removeAll() + directionSingleKeyLabels.removeAll() + padSelections.removeAll() + pendingKeyboardOptions.removeAll() + pendingSelectionFamily = nil + showKeyboardWidgetPicker = false + VirtualKeyboardView.tappedKeyLabels = [] + VirtualKeyboardView.selectedCmd = "" + } + + private func synchronizeSelectionHighlights() { + highlightedKeyIDs.removeAll() + directionSingleKeyLabels.removeAll() + padSelections.removeAll() + + guard let isCommandSelected = isCommandSelected else { return } + + for family in [KeyboardSelectionFamily.wasd, .arrow] { + let command = padCommand(for: family) + if isCommandSelected(command) { + padSelections.insert(family) + } else { + for label in labels(for: family) { + if let key = key(forLabel: label), + let singleCommand = key.cmdString(for: keyboardType), + isCommandSelected(singleCommand) { + directionSingleKeyLabels.insert(label) + } + } + } + } + + for key in allKeys { + guard selectionFamily(for: key) == nil, + let command = key.cmdString(for: keyboardType), + isCommandSelected(command) else { continue } + highlightedKeyIDs.insert(regularHighlightID(for: key)) + } + } + + private func deselectCommand(_ command: String) { + if command == "WASDPAD" { + padSelections.remove(.wasd) + } else if command == "ARROWPAD" { + padSelections.remove(.arrow) + } + + for family in [KeyboardSelectionFamily.wasd, .arrow] { + for label in labels(for: family) { + if let key = key(forLabel: label), key.cmdString(for: keyboardType) == command { + directionSingleKeyLabels.remove(label) + } + } + } + + for key in allKeys where key.cmdString(for: keyboardType) == command { + highlightedKeyIDs.remove(regularHighlightID(for: key)) + } + + if VirtualKeyboardView.selectedCmd == command { + VirtualKeyboardView.selectedCmd = "" + } + } + + private func notifyDeselection(for family: KeyboardSelectionFamily) { + if padSelections.contains(family) { + onCommandDeselected?(padCommand(for: family)) + } + + for label in labels(for: family) where directionSingleKeyLabels.contains(label) { + if let key = key(forLabel: label), let cmd = key.cmdString(for: keyboardType), !cmd.isEmpty { + onCommandDeselected?(cmd) + } + } + } + + private func key(forLabel label: String) -> Key? { + allKeys.first(where: { $0.label == label }) + } + + private var allKeys: [Key] { + mainRows.flatMap(\.keys) + + navKeys + + numpadKeys + + mouseWidgetKeys + } + + private var navKeys: [Key] { + [ + Key(label: keyboardType == .win ? "PrScr" : "F13", width: unit, winCmdString: "PRSCR", macCmdString: "F13MAC", winKeyCode: 0x2C, macKeyCode: 0x69), + Key(label: keyboardType == .win ? "ScrLk" : "F14", width: unit, winCmdString: "SCRLCK", macCmdString: "F14MAC", winKeyCode: 0x91, macKeyCode: 0x6B), + Key(label: keyboardType == .win ? "Pause" : "F15", width: unit, winCmdString: "PAUSE", macCmdString: "F15MAC", winKeyCode: 0x13, macKeyCode: 0x71), + Key(label: "Ins", width: unit, sfSymbolName:(keyboardType == .win ? nil : "filemenu.and.selection"), winCmdString: "INSERT", macCmdString: "CONTEXTMAC", winKeyCode: 0x2D, macKeyCode: 0x72), + Key(label: "Home", width: unit, winCmdString: "HOME", macCmdString: "HOMEMAC", winKeyCode: 0x24, macKeyCode: 0x73), + Key(label: "PgUp", width: unit, winCmdString: "PGUP", macCmdString: "PGUPMAC", winKeyCode: 0x21, macKeyCode: 0x74), + Key(label: "Del", width: unit, sfSymbolName:(keyboardType == .win ? nil : "delete.right"), winCmdString: "DEL", macCmdString: "DELMAC", winKeyCode: 0x2E, macKeyCode: 0x75), + Key(label: "End", width: unit, winCmdString: "END", macCmdString: "ENDMAC", winKeyCode: 0x23, macKeyCode: 0x77), + Key(label: "PgDn", width: unit, winCmdString: "PGDN", macCmdString: "PGDNMAC", winKeyCode: 0x22, macKeyCode: 0x79), + Key(label: "↑", visualStyle: .directionPad, width: unit, winCmdString: "UPARR", macCmdString: "UPARRMAC", winKeyCode: 0x26, macKeyCode: 0x7E), + Key(label: "←", visualStyle: .directionPad, width: unit, winCmdString: "LEFTARR", macCmdString: "LEFTARRMAC", winKeyCode: 0x25, macKeyCode: 0x7B), + Key(label: "↓", visualStyle: .directionPad, width: unit, winCmdString: "DOWNARR", macCmdString: "DOWNARRMAC", winKeyCode: 0x28, macKeyCode: 0x7D), + Key(label: "→", visualStyle: .directionPad, width: unit, winCmdString: "RIGHTARR", macCmdString: "RIGHTARRMAC", winKeyCode: 0x27, macKeyCode: 0x7C) + ] + } + + private var numpadKeys: [Key] { + [ + Key(label: keyboardType == .win ? "Num" : "Clear", width: unit, winCmdString: "NUMLCK", macCmdString: "NUMPADCLEARMAC", winKeyCode: 0x90, macKeyCode: 0x47), + Key(label: "/", width: unit, winCmdString: "DIVIDE", macCmdString: "NUMPADDIVIDEMAC", winKeyCode: 0x6F, macKeyCode: 0x4B), + Key(label: "*", width: unit, winCmdString: "MULTIPLY", macCmdString: "NUMPADMULTIPLYMAC", winKeyCode: 0x6A, macKeyCode: 0x43), + Key(label: "-", width: unit, winCmdString: "SUBTRACT", macCmdString: "NUMPADSUBTRACTMAC", winKeyCode: 0x6D, macKeyCode: 0x4E), + Key(label: "7", width: unit, winCmdString: "NUMPAD7", macCmdString: "NUMPAD7MAC", winKeyCode: 0x67, macKeyCode: 0x56), + Key(label: "8", width: unit, winCmdString: "NUMPAD8", macCmdString: "NUMPAD8MAC", winKeyCode: 0x68, macKeyCode: 0x57), + Key(label: "9", width: unit, winCmdString: "NUMPAD9", macCmdString: "NUMPAD9MAC", winKeyCode: 0x69, macKeyCode: 0x58), + Key(label: "4", width: unit, winCmdString: "NUMPAD4", macCmdString: "NUMPAD4MAC", winKeyCode: 0x64, macKeyCode: 0x53), + Key(label: "5", width: unit, winCmdString: "NUMPAD5", macCmdString: "NUMPAD5MAC", winKeyCode: 0x65, macKeyCode: 0x54), + Key(label: "6", width: unit, winCmdString: "NUMPAD6", macCmdString: "NUMPAD6MAC", winKeyCode: 0x66, macKeyCode: 0x55), + Key(label: "1", width: unit, winCmdString: "NUMPAD1", macCmdString: "NUMPAD1MAC", winKeyCode: 0x61, macKeyCode: 0x50), + Key(label: "2", width: unit, winCmdString: "NUMPAD2", macCmdString: "NUMPAD2MAC", winKeyCode: 0x62, macKeyCode: 0x51), + Key(label: "3", width: unit, winCmdString: "NUMPAD3", macCmdString: "NUMPAD3MAC", winKeyCode: 0x63, macKeyCode: 0x52), + Key(label: "0", width: unit * 2 + spacing, winCmdString: "NUMPAD0", macCmdString: "NUMPAD0MAC", winKeyCode: 0x60, macKeyCode: 0x4F), + Key(label: ".", width: unit, winCmdString: "DECIMAL", macCmdString: "NUMPADDECIMALMAC", winKeyCode: 0x6E, macKeyCode: 0x41), + Key(label: "+", width: unit, winCmdString: "ADD", macCmdString: "NUMPADADDMAC", winKeyCode: 0x6B, macKeyCode: 0x45), + Key(label: "=", width: unit, winCmdString: nil, macCmdString: "NUMPADEQUALMAC", winKeyCode: nil, macKeyCode: 0x51), + Key(label: "Enter", identity: "numpad-enter", width: unit, winCmdString: "ENTER", macCmdString: "ENTERMAC", winKeyCode: 0x0D, macKeyCode: 0x4C) + ] + } + + private var mouseWidgetKeys: [Key] { + [ + Key(label: SwiftLocalizationHelper.localizedString(forKey: "Left Button"), width: unit * 2 + spacing, winCmdString: "MLEFT", macCmdString: "MLEFT", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: SwiftLocalizationHelper.localizedString(forKey: "Right Button"), width: unit * 2 + spacing, winCmdString: "MRIGHT", macCmdString: "MRIGHT", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: SwiftLocalizationHelper.localizedString(forKey: "Middle Button"), width: unit * 2 + spacing, winCmdString: "MMIDDLE", macCmdString: "MMIDDLE", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: SwiftLocalizationHelper.localizedString(forKey: "X1"), width: unit, winCmdString: "MX1", macCmdString: "MX1", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: SwiftLocalizationHelper.localizedString(forKey: "X2"), width: unit, winCmdString: "MX2", macCmdString: "MX2", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: SwiftLocalizationHelper.localizedString(forKey: "Wheel\nUp"), width: unit, winCmdString: "WHEELDOWN", macCmdString: "WHEELDOWN", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: SwiftLocalizationHelper.localizedString(forKey: "Wheel\nDown"), width: unit, winCmdString: "WHEELUP", macCmdString: "WHEELUP", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: SwiftLocalizationHelper.localizedString(forKey: "WheelPad"), width: unit * 2 + spacing, winCmdString: "WHEEL", macCmdString: "WHEEL", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: SwiftLocalizationHelper.localizedString(forKey: "MousePad"), width: unit * 2 + spacing, winCmdString: "MOUSEPAD", macCmdString: "MOUSEPAD", winKeyCode: 0xFF, macKeyCode: 0xFF), + Key(label: SwiftLocalizationHelper.localizedString(forKey: "Trackball"), width: unit * 2 + spacing, winCmdString: "TRACKBALL", macCmdString: "TRACKBALL", winKeyCode: 0xFF, macKeyCode: 0xFF) + ] + } +} + +@available(iOS 13.0, *) +struct KeyView: View { + let key: Key + let height: CGFloat + let keyboardType: KeyboardType + let fontSize: CGFloat = 10 + var mode: VirtualKeyboardMode = .typing + var usesDirectionPadBaseStyle: Bool = true + var isActive: Bool = false + var highlightStyle: KeyHighlightStyle = .none + var action: (() -> Void)? = nil + + @SwiftUI.State private var isPressed = false + + var cmdString: String? { + key.cmdString(for: keyboardType) + } + + var keyCode: Int16? { + key.keyCode(for: keyboardType) + } + + private var typingHighlightFill: LinearGradient { + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.78, green: 0.84, blue: 0.92).opacity(0.95), + Color(red: 0.66, green: 0.74, blue: 0.85).opacity(0.88) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private var typingHighlightStroke: Color { + Color(red: 0.42, green: 0.52, blue: 0.66).opacity(0.78) + } + + private var keyFillColor: Color { + switch key.visualStyle { + case .standard: + return Color.gray.opacity(0.2) + case .directionPad: + return usesDirectionPadBaseStyle + ? Color(red: 0.16, green: 0.72, blue: 0.66).opacity(0.2) + : Color.gray.opacity(0.2) + } + } + + private var keyStrokeColor: Color { + switch key.visualStyle { + case .standard: + return Color.gray.opacity(0.2) + case .directionPad: + return usesDirectionPadBaseStyle + ? Color(red: 0.08, green: 0.49, blue: 0.45).opacity(0.55) + : Color.gray.opacity(0.2) + } + } + + private var foregroundColor: Color { + if mode == .typing && (highlightStyle != .none || isPressed) { + return Color.black.opacity(0.72) + } + + if highlightStyle != .none { + if mode == .typing { + return Color.black.opacity(0.72) + } + return Color.black.opacity(0.58) + } + + switch key.visualStyle { + case .standard: + return Color.primary + case .directionPad: + return Color.primary + } + } + + @ViewBuilder + private var keyContent: some View { + if let name = key.sfSymbolName, !name.isEmpty { + Image(systemName: name) + .font(.system(size: fontSize)) + } else { + Text(key.label) + .font(.system(size: fontSize, weight: .regular, design: .rounded)) + } + } + + private var backgroundColor: Color { + if highlightStyle != .none { + return Color.clear + } + + if isPressed || isActive { + return Color.gray.opacity(0.4) + } + return keyFillColor + } + + @ViewBuilder + private var keyBackground: some View { + if mode == .typing, (highlightStyle != .none || isPressed) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(typingHighlightFill) + } else if highlightStyle == .pad { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.82, green: 0.84, blue: 1.00).opacity(0.92), + Color(red: 0.56, green: 0.60, blue: 0.94).opacity(0.84) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } else if highlightStyle == .single { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 1.00, green: 0.96, blue: 0.74), + Color(red: 0.95, green: 0.78, blue: 0.32) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } else { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(backgroundColor) + } + } + + private var borderColor: Color { + if mode == .typing, (highlightStyle != .none || isPressed) { + return typingHighlightStroke + } + if highlightStyle == .pad { + return Color(red: 0.38, green: 0.42, blue: 0.82).opacity(0.90) + } + if highlightStyle == .single { + return Color.orange.opacity(0.75) + } + return keyStrokeColor + } + + var body: some View { + keyContent + .foregroundColor(foregroundColor) + .frame(width: key.width, height: height) + .background(keyBackground) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(borderColor, lineWidth: highlightStyle == .none ? (key.visualStyle == .standard ? 1 : 1.5) : 1.5) + ) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !isPressed { + isPressed = true + } + } + .onEnded { _ in + isPressed = false + action?() + print("cmdString \(String(describing: cmdString)) code \(String(describing: keyCode))") + } + ) + } +} + +@available(iOS 13.0, *) +struct KeyboardWidgetPickerOverlay: View { + let options: [KeyboardWidgetOption] + let onSelect: (KeyboardWidgetOption) -> Void + let onReset: () -> Void + let onCancel: () -> Void + + var body: some View { + ZStack { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + onCancel() + } + + VStack(spacing: 14) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Select Control")) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundColor(.white) + + VStack(spacing: 10) { + ForEach(options, id: \.self) { option in + Button(action: { + onSelect(option) + }) { + HStack(spacing: 12) { + Text(option.description) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + + Spacer() + + Text(option.command) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundColor(Color.white.opacity(0.72)) + } + .padding(.horizontal, 14) + .frame(height: 44) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + + HStack(spacing: 12) { + Button(action: { + onCancel() + }) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Cancel")) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundColor(Color.white.opacity(0.9)) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(0.16)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: { + onReset() + }) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Reset")) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundColor(Color.white.opacity(0.9)) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(0.16)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(18) + .frame(maxWidth: 320) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.15, green: 0.46, blue: 0.50), + Color(red: 0.11, green: 0.34, blue: 0.39) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 1.2) + ) + ) + .shadow(color: Color.black.opacity(0.22), radius: 20, x: 0, y: 10) + .padding(.horizontal, 24) + } + } +} + +@available(iOS 13.0, *) +struct VirtualKeyboardView_Previews: PreviewProvider { + static var previews: some View { + Group { + // VirtualKeyboardView(mode: .typing) + // .frame(height: 320) + + VirtualKeyboardView(mode: .picker) + .frame(height: 320) + .previewDisplayName("Picker") + } + } +} diff --git a/VoidLinkSwiftUI/VoidLinkSwiftUIApp.swift b/VoidLinkSwiftUI/VoidLinkSwiftUIApp.swift new file mode 100644 index 00000000..01150d49 --- /dev/null +++ b/VoidLinkSwiftUI/VoidLinkSwiftUIApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@available(iOS 14.0, *) +@main +struct VoidLinkSwiftUIApp: App { + var body: some Scene { + WindowGroup { + WidgetPickerView() + } + } +} diff --git a/VoidLinkSwiftUI/WidgetPickerView.swift b/VoidLinkSwiftUI/WidgetPickerView.swift new file mode 100644 index 00000000..d74cdcc4 --- /dev/null +++ b/VoidLinkSwiftUI/WidgetPickerView.swift @@ -0,0 +1,2458 @@ +// +// WidgetPickerView.swift +// VoidLink +// +// Created by True砖家 on 2026/4/1. +// Copyright © 2026 True砖家 @ Bilibili. All rights reserved. +// + +import SwiftUI +import UIKit + +@available(iOS 13.0, *) +enum WidgetPickerTab: String, CaseIterable, Identifiable, Equatable { + case gamepad = "Gamepad" + case keyboard = "Keyboard & Mouse" + case functional = "Functional" + + var id: String { rawValue } + + var title: String { + switch self { + case .gamepad: + return SwiftLocalizationHelper.localizedString(forKey: "Gamepad") + case .keyboard: + return SwiftLocalizationHelper.localizedString(forKey: "Keyboard / Mouse") + case .functional: + return SwiftLocalizationHelper.localizedString(forKey: "Functional Buttons") + } + } + + init?(identifier: String) { + switch identifier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "gamepad", "Gamepad": + self = .gamepad + case "keyboard", "keyboardmouse", "keyboard_mouse", "keyboard-mouse": + self = .keyboard + case "functional", "function", "functions": + self = .functional + default: + return nil + } + } +} + +@available(iOS 13.0, *) +enum WidgetPoolSource: Equatable { + case gamepad + case keyboard + case functional +} + +@available(iOS 13.0, *) +enum WidgetPoolVisualKind: Equatable { + case gamepadButton + case gamepadPad + case keyboardButton + case keyboardPad + case functionalButton +} + +@available(iOS 13.0, *) +struct WidgetPoolItem: Identifiable { + let id = UUID() + let cmd: String + let source: WidgetPoolSource + let visualKind: WidgetPoolVisualKind + let staysAtTail: Bool + let displayText: String? + + var displayCmd: String { + switch source { + case .gamepad: + if cmd.hasPrefix("OSC") { + return String(cmd.dropFirst(3)) + } + return cmd + case .keyboard: + return displayText ?? cmd + case .functional: + return cmd + } + } + + var span: Int { + switch visualKind { + case .gamepadButton, .keyboardButton, .functionalButton: + return 1 + case .gamepadPad, .keyboardPad: + return 2 + } + } +} + +@available(iOS 13.0, *) +struct WidgetPoolPlacement: Identifiable { + let id: UUID + let item: WidgetPoolItem + let row: Int + let column: Int +} + +@available(iOS 13.0, *) +private enum PoolPadHighlightStyle { + static let fillTop = Color(red: 0.82, green: 0.84, blue: 1.00).opacity(0.92) + static let fillBottom = Color(red: 0.56, green: 0.60, blue: 0.94).opacity(0.84) + static let stroke = Color(red: 0.38, green: 0.42, blue: 0.82).opacity(0.90) +} + +@available(iOS 13.0, *) +private enum WidgetCreateTargetKind { + case button + case pad +} + +@available(iOS 13.0, *) +private enum WidgetPickerSubmissionAction: String { + case create + case modify + + var payloadValue: String { rawValue } + + var confirmationTitle: String { + switch self { + case .create: + return SwiftLocalizationHelper.localizedString(forKey: "Create New") + case .modify: + return SwiftLocalizationHelper.localizedString(forKey: "Modify") + } + } + + var poolActionTitle: String { + switch self { + case .create: + return SwiftLocalizationHelper.localizedString(forKey: "Create New") + case .modify: + return SwiftLocalizationHelper.localizedString(forKey: "Modify") + } + } +} + +@available(iOS 13.0, *) +enum WidgetCreateComboMode: String, CaseIterable, Identifiable { + case skill + case shortcut + + var id: String { rawValue } + + var title: String { + switch self { + case .skill: + return SwiftLocalizationHelper.localizedString(forKey: "Skill combo") + case .shortcut: + return SwiftLocalizationHelper.localizedString(forKey: "Shortcut combo") + } + } +} + +@available(iOS 13.0, *) +private enum WidgetCreateShape: String, CaseIterable, Identifiable { + case round = "r" + case square = "s" + + var id: String { rawValue } + + var title: String { + switch self { + case .round: + return SwiftLocalizationHelper.localizedString(forKey: "Circle") + case .square: + return SwiftLocalizationHelper.localizedString(forKey: "Square") + } + } +} + +@available(iOS 13.0, *) +struct GyroButtonOption: Identifiable { + let id = UUID() + let cmd: String + let description: String +} + +@available(iOS 13.0, *) +struct FunctionalButtonOption: Identifiable { + let id = UUID() + let label: String + let cmd: String + let tip: String + let allowsKeyboardCombination: Bool + let allowsGamepadCombination: Bool + let allowsSkillCombo: Bool + let allowsShortcutCombo: Bool + let forcedComboMode: WidgetCreateComboMode? +} + +@available(iOS 13.0, *) +private struct WidgetPickerMetrics { + let isPhone: Bool + let outerPadding: CGFloat + let panelSpacing: CGFloat + let blurPadding: CGFloat + let blurCornerRadius: CGFloat + let panelPadding: CGFloat + let panelCornerRadius: CGFloat + let panelShadowRadius: CGFloat + let panelShadowYOffset: CGFloat + let sectionSpacing: CGFloat + let pickerSectionSpacing: CGFloat + let poolSectionSpacing: CGFloat + let headerSpacing: CGFloat + let titleFontSize: CGFloat + let poolTitleFontSize: CGFloat + let subtitleFontSize: CGFloat + let tabSpacing: CGFloat + let tabFontSize: CGFloat + let tabHorizontalPadding: CGFloat + let tabHeight: CGFloat + let pickerInsetKeyboard: CGFloat + let pickerInsetGamepadHorizontal: CGFloat + let pickerInsetGamepadVertical: CGFloat + let pickerInsetFunctional: CGFloat + let pickerSurfaceCornerRadius: CGFloat + let tipHeight: CGFloat + let tipFontSize: CGFloat + let chipHeight: CGFloat + let chipFontSize: CGFloat + let chipCornerRadius: CGFloat + let poolGridSpacing: CGFloat + let poolGridInset: CGFloat + let rightPanelMinWidth: CGFloat + let leftWidthRatio: CGFloat + + static func make(for isPhone: Bool) -> WidgetPickerMetrics { + WidgetPickerMetrics( + isPhone: isPhone, + outerPadding: isPhone ? 4 : 24, + panelSpacing: isPhone ? 6 : 22, + blurPadding: isPhone ? 4 : 18, + blurCornerRadius: isPhone ? 20 : 34, + panelPadding: isPhone ? 7 : 22, + panelCornerRadius: isPhone ? 18 : 30, + panelShadowRadius: isPhone ? 8 : 22, + panelShadowYOffset: isPhone ? 4 : 12, + sectionSpacing: isPhone ? 2.5 : 18, + pickerSectionSpacing: isPhone ? 3 : 13, + poolSectionSpacing: isPhone ? 9 : 24, + headerSpacing: isPhone ? 0.5 : 6, + titleFontSize: isPhone ? 11.5 : 28, + poolTitleFontSize: isPhone ? 11 : 20, + subtitleFontSize: isPhone ? 7 : 13, + tabSpacing: isPhone ? 4 : 10, + tabFontSize: isPhone ? 7.2 : 13, + tabHorizontalPadding: isPhone ? 5 : 16, + tabHeight: isPhone ? 16 : 38, + pickerInsetKeyboard: isPhone ? 1 : 16, + pickerInsetGamepadHorizontal: isPhone ? 10 : 24, + pickerInsetGamepadVertical: isPhone ? 10 : 24, + pickerInsetFunctional: isPhone ? 2 : 24, + pickerSurfaceCornerRadius: isPhone ? 14 : 28, + tipHeight: isPhone ? 36 : 68, + tipFontSize: isPhone ? 9.5 : 13, + chipHeight: isPhone ? 24 : 46, + chipFontSize: isPhone ? 9.5 : 14, + chipCornerRadius: isPhone ? 9 : 16, + poolGridSpacing: isPhone ? 3 : 10, + poolGridInset: isPhone ? 5 : 14, + rightPanelMinWidth: isPhone ? 180 : 280, + leftWidthRatio: isPhone ? 0.68 : 0.64 + ) + } +} + +@available(iOS 13.0, *) +struct WidgetPickerView: View { + private enum PoolTipMessageType { + case normal + case error + } + + private let maxPoolSlots = 16 + private let isEditMode: Bool + private let initialCmdString: String? + private let initialButtonLabel: String? + private let initialShape: String? + private let availableTabs: [WidgetPickerTab] + private let preferredInitialTab: WidgetPickerTab? + var onWidgetCreated: (([String: String]) -> Void)? = nil + var onCloseRequested: (() -> Void)? = nil + + @SwiftUI.State private var selectedTab: WidgetPickerTab + @SwiftUI.State private var selectedGamepadType: GamepadType = .xbox + @SwiftUI.State private var selectedCmds: [String] = [] + @SwiftUI.State private var poolItems: [WidgetPoolItem] = [] + @SwiftUI.State private var resetToken: Int = 0 + @SwiftUI.State private var tipMessage: String = SwiftLocalizationHelper.localizedString(forKey: "Tap any control to add it") + @SwiftUI.State private var tipMessageType: PoolTipMessageType = .normal + @SwiftUI.State private var keyboardDeselectionCommand: String = "" + @SwiftUI.State private var keyboardDeselectionToken: Int = 0 + @SwiftUI.State private var gamepadDeselectionCommand: String = "" + @SwiftUI.State private var gamepadDeselectionToken: Int = 0 + @SwiftUI.State private var showGyroPicker = false + @SwiftUI.State private var selectedGyroCommand: String? = nil + @SwiftUI.State private var showCreateWidgetSheet = false + @SwiftUI.State private var widgetComboMode: WidgetCreateComboMode = .skill + @SwiftUI.State private var widgetTriggerInterval: Double = 0 + @SwiftUI.State private var widgetShape: WidgetCreateShape = .round + @SwiftUI.State private var widgetButtonLabel: String = "" + @SwiftUI.State private var lastCreatedWidgetPayload: [String: String]? = nil + @SwiftUI.State private var pendingSubmissionAction: WidgetPickerSubmissionAction = .create + @SwiftUI.State private var didApplyInitialConfiguration: Bool = false + @SwiftUI.State private var selectionSyncToken: Int = 0 + + init( + isEditMode: Bool = false, + initialCmdString: String? = nil, + initialButtonLabel: String? = nil, + initialShape: String? = nil, + availableTabs: [WidgetPickerTab] = WidgetPickerTab.allCases, + preferredInitialTab: WidgetPickerTab? = nil, + onWidgetCreated: (([String: String]) -> Void)? = nil, + onCloseRequested: (() -> Void)? = nil + ) { + let normalizedTabs = availableTabs.isEmpty ? WidgetPickerTab.allCases : availableTabs + let resolvedInitialTab = preferredInitialTab.flatMap { normalizedTabs.contains($0) ? $0 : nil } + self.isEditMode = isEditMode + self.initialCmdString = initialCmdString + self.initialButtonLabel = initialButtonLabel + self.initialShape = initialShape + self.availableTabs = normalizedTabs + self.preferredInitialTab = resolvedInitialTab + self.onWidgetCreated = onWidgetCreated + self.onCloseRequested = onCloseRequested + _selectedTab = SwiftUI.State(initialValue: resolvedInitialTab ?? (normalizedTabs.contains(.keyboard) ? .keyboard : normalizedTabs[0])) + } + + private let functionalButtonOptions: [FunctionalButtonOption] = [ + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Settings"), + cmd: "SETTINGS", + tip: SwiftLocalizationHelper.localizedString(forKey: "Expands setting menu during streaming"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Folder"), + cmd: "FOLDER", + tip: SwiftLocalizationHelper.localizedString(forKey: "Collect, fold & unfold other widgets or subfolders"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Toolbox"), + cmd: "TOOLBOX", + tip: SwiftLocalizationHelper.localizedString(forKey: "Activate toolbox menu during streaming"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Edit Layout"), + cmd: "WIDGETTOOL", + tip: SwiftLocalizationHelper.localizedString(forKey: "Open on-screen widget layout tool"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Pick profile"), + cmd: "PICKPRFL", + tip: SwiftLocalizationHelper.localizedString(forKey: "Pick a game/on-screen-widget profile"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Profiles"), + cmd: "PROFILES", + tip: SwiftLocalizationHelper.localizedString(forKey: "Select a game/on-screen-widget profile"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "SoftKeyboard"), + cmd: "SOFTKEYBOARD", + tip: SwiftLocalizationHelper.localizedString(forKey: "Bring up softkeyboard"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Disable touch"), + cmd: "DISABLETOUCH", + tip: SwiftLocalizationHelper.localizedString(forKey: "Temporarily disable touch input for stream view"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Pressure curve"), + cmd: "PRESSURECURVE", + tip: SwiftLocalizationHelper.localizedString(forKey: "Opens pencil pressure curve tool"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Pencil hover"), + cmd: "PENCILHOVER", + tip: SwiftLocalizationHelper.localizedString(forKey: "Force pencil entering hovering on host"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Brush shortcut"), + cmd: "BRUSH", + tip: SwiftLocalizationHelper.localizedString(forKey: "Combine this with a brush keyboard shortcut for PC software"), + allowsKeyboardCombination: true, + allowsGamepadCombination: true, + allowsSkillCombo: false, + allowsShortcutCombo: true, + forcedComboMode: .shortcut + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Eraser shortcut"), + cmd: "ERASER", + tip: SwiftLocalizationHelper.localizedString(forKey: "Combine this with a eraser keyboard shortcut for PC software"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: true, + forcedComboMode: .shortcut + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Disable single point touch"), + cmd: "NOSINGLETOUCH", + tip: SwiftLocalizationHelper.localizedString(forKey: "Disable single finger touch for stream view"), + allowsKeyboardCombination: false, + allowsGamepadCombination: false, + allowsSkillCombo: false, + allowsShortcutCombo: false, + forcedComboMode: nil + ), + FunctionalButtonOption( + label: SwiftLocalizationHelper.localizedString(forKey: "Absolute touch drag"), + cmd: "ABSTCHDRAG", + tip: SwiftLocalizationHelper.localizedString(forKey: "Replace mouse button action in single point touch mode with another button temporarily"), + allowsKeyboardCombination: true, + allowsGamepadCombination: true, + allowsSkillCombo: true, + allowsShortcutCombo: false, + forcedComboMode: .skill + ), + ] + + private var defaultTipMessage: String { + SwiftLocalizationHelper.localizedString(forKey: "Tap any control to add it") + } + + private var tipMessageColor: Color { + switch tipMessageType { + case .normal: + return Color.black.opacity(1) + case .error: + return Color(red: 0.76, green: 0.30, blue: 0.22) + } + } + + private func setTipMessage(_ message: String, type: PoolTipMessageType = .normal) { + tipMessage = message + tipMessageType = type + } + + private func resetTipMessage() { + setTipMessage(defaultTipMessage) + } + + private var hasAnySingleItem: Bool { + poolItems.count == 1 + } + + private var hasMultipleButtonItemsOnly: Bool { + poolItems.count > 1 && poolItems.allSatisfy { !isPad($0) } + } + + private var hasButtonThenPadCombination: Bool { + guard let firstItem = poolItems.first, let lastItem = poolItems.last else { return false } + return !isPad(firstItem) && isPad(lastItem) && poolItems.contains(where: isPad) && poolItems.contains(where: { !isPad($0) }) + } + + private var hasPadThenButtonCombination: Bool { + guard let firstItem = poolItems.first, let lastItem = poolItems.last else { return false } + return isPad(firstItem) && !isPad(lastItem) && poolItems.contains(where: isPad) && poolItems.contains(where: { !isPad($0) }) + } + + private func updateTipMessageForCurrentPoolState() { + if poolItems.isEmpty { + resetTipMessage() + } else if hasButtonThenPadCombination { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "A button with touchpad functionality will be created")) + } else if hasPadThenButtonCombination { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "A touchpad with double-tap button triggering will be created")) + } else if let selectedFunctionalButtonOption { + setTipMessage(selectedFunctionalButtonOption.tip) + } else if hasAnySingleItem || hasMultipleButtonItemsOnly { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "You can continue adding controls to this combination")) + } else { + resetTipMessage() + } + } + + var body: some View { + GeometryReader { proxy in + let metrics = WidgetPickerMetrics.make(for: UIDevice.current.userInterfaceIdiom == .phone) + let usesVerticalLayout = proxy.size.height > proxy.size.width + let isPadPortrait = !metrics.isPhone && usesVerticalLayout + let isPhonePortrait = metrics.isPhone && usesVerticalLayout + let verticalOuterPadding = isPhonePortrait ? metrics.outerPadding * 0.5 : metrics.outerPadding + let reclaimedBottomSafeArea = isPhonePortrait ? proxy.safeAreaInsets.bottom : 0 + let leftWidth = max(metrics.isPhone ? 0 : 420, proxy.size.width * metrics.leftWidthRatio) + let rightWidth = max( + metrics.rightPanelMinWidth, + proxy.size.width - leftWidth - metrics.panelSpacing - metrics.outerPadding * 2 + ) + let sharedVerticalAvailableHeight = proxy.size.height - metrics.panelSpacing - verticalOuterPadding * 2 + let poolVerticalBonusHeight = isPhonePortrait ? 16.0 : 20.0 + let sharedVerticalHeight = max(220, sharedVerticalAvailableHeight * 0.5) + let verticalPoolHeight = max(220, sharedVerticalHeight + poolVerticalBonusHeight) + let verticalPickerHeight = max(220, sharedVerticalAvailableHeight - verticalPoolHeight) + let bottomStackPadding = isPhonePortrait ? (verticalOuterPadding - reclaimedBottomSafeArea) : verticalOuterPadding + + ZStack { + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.95, green: 0.97, blue: 1.0), + Color(red: 0.84, green: 0.92, blue: 0.94) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .edgesIgnoringSafeArea(.all) + + RoundedRectangle(cornerRadius: metrics.blurCornerRadius, style: .continuous) + .fill(Color.white.opacity(0.30)) + .blur(radius: 40) + .padding(metrics.blurPadding) + + Group { + if usesVerticalLayout { + VStack(alignment: .leading, spacing: metrics.panelSpacing) { + widgetPoolPanel(metrics: metrics, isPadPortrait: isPadPortrait) + .frame(maxWidth: .infinity) + .frame(height: verticalPoolHeight) + + widgetPickerPanel(metrics: metrics, isPadPortrait: isPadPortrait) + .frame(maxWidth: .infinity) + .frame(height: verticalPickerHeight) + } + } else { + HStack(alignment: .top, spacing: metrics.panelSpacing) { + widgetPoolPanel(metrics: metrics, isPadPortrait: isPadPortrait) + .frame(width: rightWidth) + + widgetPickerPanel(metrics: metrics, isPadPortrait: isPadPortrait) + .frame(width: leftWidth) + } + } + } + .padding(.top, verticalOuterPadding) + .padding(.leading, metrics.outerPadding) + .padding(.trailing, metrics.outerPadding) + .padding(.bottom, bottomStackPadding) + .edgesIgnoringSafeArea(.bottom) + + if showGyroPicker { + GyroButtonPickerOverlay( + options: gyroOptions, + onSelect: { option in + selectGyroOption(option) + }, + onCancel: { + showGyroPicker = false + } + ) + .zIndex(999) + } + + if showCreateWidgetSheet { + createWidgetSheet + .zIndex(1000) + } + } + } + .onAppear { + applyInitialConfigurationIfNeeded() + } + } + + private func widgetPickerPanel(metrics: WidgetPickerMetrics, isPadPortrait: Bool = false) -> some View { + let pickerSectionSpacing = isPadPortrait ? 9.0 : metrics.pickerSectionSpacing + let pickerTitleFontSize = isPadPortrait ? 20.0 : metrics.poolTitleFontSize + let pickerPanelPadding = isPadPortrait ? 14.0 : metrics.panelPadding + let pickerInsetGamepadHorizontal = isPadPortrait ? 10.0 : metrics.pickerInsetGamepadHorizontal + let pickerInsetGamepadVertical = isPadPortrait ? 50 : metrics.pickerInsetGamepadVertical + let pickerInsetKeyboard = isPadPortrait ? 6.0 : metrics.pickerInsetKeyboard + let pickerInsetFunctional = isPadPortrait ? 10.0 : metrics.pickerInsetFunctional + let modeButtonFontSize = isPadPortrait ? 11.0 : max(metrics.tabFontSize, 10) + let modeButtonHeight = isPadPortrait ? 28.0 : max(metrics.tabHeight, 22) + let modeButtonHorizontalPadding = isPadPortrait ? 10.0 : 8.0 + let pickerHeaderMinHeight = isPadPortrait ? 0.0 : 0.0 + + return ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: pickerSectionSpacing) { + HStack(alignment: .center, spacing: 10) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Widget Picker")) + .font(.system(size: pickerTitleFontSize, weight: .bold, design: .rounded)) + .foregroundColor(Color.black.opacity(0.82)) + .lineLimit(1) + .minimumScaleFactor(0.8) + + Spacer(minLength: 8) + } + .frame(minHeight: pickerHeaderMinHeight, alignment: .topLeading) + + widgetPickerTabBar(metrics: metrics, isPadPortrait: isPadPortrait) + + ZStack { + pickerSurface(metrics: metrics) + + switch selectedTab { + case .gamepad: + AbstractGamepadView( + gamepadType: selectedGamepadType, + canSelectCommand: { canSelectCommand($0, source: .gamepad) }, + isCommandSelected: { selectedCmds.contains($0) }, + onCommandSelected: { appendCommand($0, source: .gamepad) }, + onCommandDeselected: { removeCommand($0, source: .gamepad) }, + resetToken: resetToken, + selectionSyncToken: selectionSyncToken, + externalDeselectionCommand: gamepadDeselectionCommand, + externalDeselectionToken: gamepadDeselectionToken + ) + .padding(.horizontal, pickerInsetGamepadHorizontal) + .padding(.vertical, pickerInsetGamepadVertical) + case .keyboard: + VirtualKeyboardView( + mode: .picker, + canSelectCommand: { canSelectCommand($0, source: .keyboard) }, + isCommandSelected: { selectedCmds.contains($0) }, + onCommandSelected: { appendCommand($0, source: .keyboard) }, + onCommandDeselected: { removeCommand($0, source: .keyboard) }, + resetToken: resetToken, + selectionSyncToken: selectionSyncToken, + externalDeselectionCommand: keyboardDeselectionCommand, + externalDeselectionToken: keyboardDeselectionToken + ) + .padding(pickerInsetKeyboard) + case .functional: + FunctionalButtonCollectionView( + items: functionalButtonOptions, + isSelected: { selectedCmds.contains($0) }, + onSelect: { handleFunctionalButtonSelection($0) }, + onDeselect: { handleFunctionalButtonDeselection($0) } + ) + .padding(pickerInsetFunctional) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .mask( + Group { + if selectedTab == .gamepad { + Rectangle() + } else { + RoundedRectangle(cornerRadius: metrics.pickerSurfaceCornerRadius, style: .continuous) + } + } + ) + } + + HStack(spacing: 8) { + if selectedTab == .gamepad { + Button(action: { + selectedGamepadType = selectedGamepadType == .xbox ? .ps : .xbox + }) { + HStack(spacing: 6) { + Image(systemName: selectedGamepadType == .xbox ? "xmark.circle.fill" : "p.circle.fill") + .font(.system(size: max(modeButtonFontSize - 1, 9), weight: .bold)) + + Text(selectedGamepadType == .xbox ? "Xbox" : "PS") + .font(.system(size: modeButtonFontSize, weight: .bold, design: .rounded)) + } + .foregroundColor(Color.black.opacity(0.66)) + .padding(.horizontal, modeButtonHorizontalPadding) + .frame(height: modeButtonHeight) + .background( + Capsule() + .fill(Color.white.opacity(0.78)) + ) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.9), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.05), radius: 4, x: 0, y: 2) + } + .buttonStyle(PlainButtonStyle()) + } + + Button(action: { + onCloseRequested?() + }) { + Image(systemName: "xmark") + .font(.system(size: max(modeButtonFontSize, 10), weight: .bold)) + .foregroundColor(Color.black.opacity(0.66)) + .frame(width: modeButtonHeight, height: modeButtonHeight) + .background( + Circle() + .fill(Color.white.opacity(0.78)) + ) + .overlay( + Circle() + .stroke(Color.white.opacity(0.9), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.05), radius: 4, x: 0, y: 2) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(pickerPanelPadding) + .frame(maxHeight: .infinity, alignment: .top) + .background(panelCardFill(metrics: metrics)) + .overlay(panelCardStroke(metrics: metrics)) + .shadow(color: Color.black.opacity(0.08), radius: metrics.panelShadowRadius, x: 0, y: metrics.panelShadowYOffset) + } + + private func widgetPickerTabBar(metrics: WidgetPickerMetrics, isPadPortrait: Bool = false) -> some View { + let tabSpacing = isPadPortrait ? 8.0 : metrics.tabSpacing + let tabFontSize = isPadPortrait ? 12.0 : metrics.tabFontSize + let tabHorizontalPadding = isPadPortrait ? 10.0 : metrics.tabHorizontalPadding + let tabHeight = isPadPortrait ? 30.0 : metrics.tabHeight + + return HStack(spacing: tabSpacing) { + ForEach(availableTabs) { tab in + Button(action: { + selectedTab = tab + }) { + Text(tab.title) + .font(.system(size: tabFontSize, weight: .bold, design: .rounded)) + .foregroundColor(selectedTab == tab ? Color.white : Color.black.opacity(0.56)) + .padding(.horizontal, tabHorizontalPadding) + .frame(height: tabHeight) + .background( + Capsule() + .fill( + selectedTab == tab + ? LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.18, green: 0.61, blue: 0.67), + Color(red: 0.13, green: 0.45, blue: 0.52) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + : LinearGradient( + gradient: Gradient(colors: [ + Color.white.opacity(0.75), + Color.white.opacity(0.38) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .overlay( + Capsule() + .stroke(Color.white.opacity(selectedTab == tab ? 0.18 : 0.55), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + + private func widgetPoolPanel(metrics: WidgetPickerMetrics, isPadPortrait: Bool) -> some View { + let poolTitleFontSize = isPadPortrait ? 20.0 : metrics.poolTitleFontSize + let poolSubtitleFontSize = isPadPortrait ? 11.0 : metrics.subtitleFontSize + let poolHeaderSpacing = metrics.headerSpacing + (metrics.isPhone ? 0 : (isPadPortrait ? 2 : 19)) + let poolTipHeight = isPadPortrait ? 54.0 : metrics.tipHeight + let poolTipFontSize = isPadPortrait ? 11.0 : metrics.tipFontSize + let poolChipHeight = isPadPortrait ? 36.0 : metrics.chipHeight + let poolChipFontSize = isPadPortrait ? 12.0 : metrics.chipFontSize + let poolGridWidth: CGFloat? = isPadPortrait ? min(240, UIScreen.main.bounds.width * 0.34) : nil + + return VStack(alignment: .leading, spacing: metrics.poolSectionSpacing) { + VStack(alignment: .leading, spacing: poolHeaderSpacing) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Widget Pool")) + .font(.system(size: poolTitleFontSize, weight: .bold, design: .rounded)) + .foregroundColor(Color.black.opacity(0.82)) + .lineLimit(1) + .minimumScaleFactor(0.8) + + + Text(SwiftLocalizationHelper.localizedString(forKey: "Selected widgets are queued here in tap order.")) + .font(.system(size: poolSubtitleFontSize, weight: .medium, design: .rounded)) + .foregroundColor(Color.black.opacity(0.48)) + .lineLimit(metrics.isPhone ? 1 : 2) + .minimumScaleFactor(0.85) + } + + poolTipArea(metrics: metrics, customHeight: poolTipHeight, customFontSize: poolTipFontSize) + + WidgetPoolGridView( + items: poolItems, + onItemTap: { handlePoolItemTap($0) }, + spacing: metrics.poolGridSpacing, + contentInset: metrics.poolGridInset + ) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: poolGridWidth ?? .infinity, alignment: .center) + .frame(maxWidth: .infinity, alignment: .center) + + PoolActionChip( + title: SwiftLocalizationHelper.localizedString(forKey: "Gyro switch button"), + isPrimary: false, + height: poolChipHeight, + fontSize: poolChipFontSize, + cornerRadius: metrics.chipCornerRadius + ) { + handleGyroButtonTap() + } + .overlay( + Group { + if selectedGyroCommand != nil { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.orange.opacity(0.75), lineWidth: 1.4) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 1.00, green: 0.96, blue: 0.74).opacity(0.34), + Color(red: 0.95, green: 0.78, blue: 0.32).opacity(0.22) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .allowsHitTesting(false) + } + } + ) + + HStack(spacing: metrics.isPhone ? 8 : 12) { + PoolActionChip( + title: isEditMode + ? WidgetPickerSubmissionAction.create.poolActionTitle + : SwiftLocalizationHelper.localizedString(forKey: "Create widget"), + isPrimary: true, + height: poolChipHeight, + fontSize: poolChipFontSize, + cornerRadius: metrics.chipCornerRadius + ) { + presentCreateWidgetSheet(for: .create) + } + + if isEditMode { + PoolActionChip( + title: WidgetPickerSubmissionAction.modify.poolActionTitle, + isPrimary: false, + height: poolChipHeight, + fontSize: poolChipFontSize, + cornerRadius: metrics.chipCornerRadius + ) { + presentCreateWidgetSheet(for: .modify) + } + } + + PoolActionChip( + title: SwiftLocalizationHelper.localizedString(forKey: "Reset"), + isPrimary: false, + height: poolChipHeight, + fontSize: poolChipFontSize, + cornerRadius: metrics.chipCornerRadius + ) { + selectedCmds.removeAll() + poolItems.removeAll() + selectedGyroCommand = nil + showGyroPicker = false + resetTipMessage() + resetToken += 1 + selectionSyncToken += 1 + } + } + } + .padding(metrics.panelPadding) + .frame(maxHeight: .infinity, alignment: .top) + .background(panelCardFill(metrics: metrics)) + .overlay(panelCardStroke(metrics: metrics)) + .shadow(color: Color.black.opacity(0.08), radius: metrics.panelShadowRadius, x: 0, y: metrics.panelShadowYOffset) + } + + private func poolTipArea(metrics: WidgetPickerMetrics, customHeight: CGFloat? = nil, customFontSize: CGFloat? = nil) -> some View { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.white.opacity(0.55)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.72), lineWidth: 1) + ) + .frame(height: customHeight ?? metrics.tipHeight) + .overlay( + HStack { + Text(tipMessage) + .font(.system(size: customFontSize ?? metrics.tipFontSize, weight: .semibold, design: .rounded)) + .foregroundColor(tipMessageColor) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + .padding(.horizontal, metrics.isPhone ? 10 : 14) + ) + } + + private func pickerSurface(metrics: WidgetPickerMetrics) -> some View { + RoundedRectangle(cornerRadius: metrics.pickerSurfaceCornerRadius, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color.white.opacity(0.88), + Color(red: 0.90, green: 0.95, blue: 0.96).opacity(0.98) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: metrics.pickerSurfaceCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.80), lineWidth: 1.2) + ) + } + + private func panelCardFill(metrics: WidgetPickerMetrics) -> some View { + RoundedRectangle(cornerRadius: metrics.panelCornerRadius, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color.white.opacity(0.72), + Color.white.opacity(0.54) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + + private func panelCardStroke(metrics: WidgetPickerMetrics) -> some View { + RoundedRectangle(cornerRadius: metrics.panelCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.82), lineWidth: 1.1) + } + + private func appendCommand(_ cmd: String, source: WidgetPoolSource) { + let existingCount = poolItems.count + guard !cmd.isEmpty else { return } + let item = WidgetPoolItem( + cmd: cmd, + source: source, + visualKind: visualKind(for: cmd, source: source), + staysAtTail: shouldMarkItemAsTailLocked(cmd: cmd, source: source, existingCount: existingCount), + displayText: keyboardPoolDisplayText(for: cmd, source: source) + ) + + if shouldInsertCommandAtFront(cmd) { + selectedCmds.insert(cmd, at: 0) + poolItems.insert(item, at: 0) + } else if shouldKeepItemAtPoolTail(item), !poolItems.isEmpty { + selectedCmds.append(cmd) + poolItems.append(item) + } else if let trailingPadIndex = trailingCombinablePadIndex { + selectedCmds.insert(cmd, at: trailingPadIndex) + poolItems.insert(item, at: trailingPadIndex) + } else { + selectedCmds.append(cmd) + poolItems.append(item) + } + updateTipMessageForCurrentPoolState() + } + + private func visualKind(for cmd: String, source: WidgetPoolSource) -> WidgetPoolVisualKind { + switch source { + case .gamepad: + return gamepadPadCommands.contains(cmd) ? .gamepadPad : .gamepadButton + case .functional: + return .functionalButton + case .keyboard: + return keyboardPadCommands.contains(cmd) ? .keyboardPad : .keyboardButton + } + } + + private var keyboardPadCommands: Set { + [ + "WASDPAD", + "ARROWPAD", + "WHEEL", + "MOUSEPAD", + "TRACKBALL" + ] + } + + private var gamepadPadCommands: Set { + [ + "DPAD", + "LSWHEEL", + "LSPAD", + "RSPAD", + "RSVPAD", + "LTPAD", + "RTPAD", + "DS4TOUCH" + ] + } + + private var directionPadPriorityCommands: Set { + [ + "WASDPAD", + "ARROWPAD", + "DPAD" + ] + } + + private var gyroCommands: Set { + [ + "GYRO", + "GYROPAUSE" + ] + } + + private var priorityFirstCommands: Set { + directionPadPriorityCommands.union(gyroCommands) + } + + private func shouldInsertCommandAtFront(_ cmd: String) -> Bool { + if priorityFirstCommands.contains(cmd) { + return true + } + + if cmd == "RS", poolItems.contains(where: { $0.cmd == "RSPAD" || $0.cmd == "RSVPAD" }) { + return true + } + + return false + } + + private func shouldMarkItemAsTailLocked(cmd: String, source: WidgetPoolSource, existingCount: Int) -> Bool { + let item = WidgetPoolItem( + cmd: cmd, + source: source, + visualKind: visualKind(for: cmd, source: source), + staysAtTail: false, + displayText: keyboardPoolDisplayText(for: cmd, source: source) + ) + return existingCount > 0 && isCombinableNonDirectionPad(item) + } + + private func keyboardPoolDisplayText(for cmd: String, source: WidgetPoolSource) -> String? { + guard source == .keyboard else { return nil } + if VirtualKeyboardView.lastSelectionUsesMacLayout + || VirtualKeyboardView.lastSelectionFromMouseWidgets + || VirtualKeyboardView.lastSelectionUsesFnCommandDisplay { + return cmd + } + return VirtualKeyboardView.lastSelectionDisplayText ?? cmd + } + + private func shouldKeepItemAtPoolTail(_ item: WidgetPoolItem) -> Bool { + isCombinableNonDirectionPad(item) && !poolItems.isEmpty + } + + private var trailingCombinablePadIndex: Int? { + guard let lastItem = poolItems.last, lastItem.staysAtTail, isCombinableNonDirectionPad(lastItem) else { + return nil + } + return poolItems.count - 1 + } + + private func isCombinableNonDirectionPad(_ item: WidgetPoolItem) -> Bool { + guard isPad(item) else { return false } + if directionPadPriorityCommands.contains(item.cmd) { return false } + if item.cmd == "LSWHEEL" { return false } + return true + } + + private var wasdSingleCommands: Set { + [ + "W", + "A", + "S", + "D", + "WMAC", + "AMAC", + "SMAC", + "DMAC" + ] + } + + private var arrowSingleCommands: Set { + [ + "UPARR", + "DOWNARR", + "LEFTARR", + "RIGHTARR", + "UPARRMAC", + "DOWNARRMAC", + "LEFTARRMAC", + "RIGHTARRMAC" + ] + } + + private var dpadSingleCommands: Set { + [ + "OSCUP", + "OSCDOWN", + "OSCLEFT", + "OSCRIGHT" + ] + } + + private func triggerGroup(for cmd: String) -> String? { + switch cmd { + case "LT", "LTPAD": + return "LT" + case "RT", "RTPAD": + return "RT" + default: + return nil + } + } + + private func directionPadGroup(for cmd: String) -> String? { + if cmd == "WASDPAD" || wasdSingleCommands.contains(cmd) { + return "WASD" + } + + if cmd == "ARROWPAD" || arrowSingleCommands.contains(cmd) { + return "ARROW" + } + + if cmd == "DPAD" || dpadSingleCommands.contains(cmd) { + return "DPAD" + } + + return nil + } + + private func isDirectionPadContainerCommand(_ cmd: String) -> Bool { + directionPadPriorityCommands.contains(cmd) + } + + private func isDirectionPadSingleCommand(_ cmd: String) -> Bool { + wasdSingleCommands.contains(cmd) || arrowSingleCommands.contains(cmd) || dpadSingleCommands.contains(cmd) + } + + private func removeCommand(_ cmd: String, source: WidgetPoolSource) { + guard !cmd.isEmpty else { return } + + if let cmdIndex = selectedCmds.firstIndex(of: cmd) { + selectedCmds.remove(at: cmdIndex) + } + + if let itemIndex = poolItems.firstIndex(where: { $0.cmd == cmd && $0.source == source }) { + poolItems.remove(at: itemIndex) + } + + updateTipMessageForCurrentPoolState() + selectionSyncToken += 1 + } + + private func handlePoolItemTap(_ item: WidgetPoolItem) { + removeCommand(item.cmd, source: item.source) + + switch item.source { + case .keyboard: + keyboardDeselectionCommand = item.cmd + keyboardDeselectionToken += 1 + case .gamepad: + gamepadDeselectionCommand = item.cmd + gamepadDeselectionToken += 1 + case .functional: + if selectedGyroCommand == item.cmd { + selectedGyroCommand = nil + } + } + } + + private func isPad(_ item: WidgetPoolItem) -> Bool { + item.visualKind == .keyboardPad || item.visualKind == .gamepadPad + } + + private func canSelectCommand(_ cmd: String, source: WidgetPoolSource) -> Bool { + if selectedCmds.contains(cmd) { + return false + } + + let candidate = WidgetPoolItem( + cmd: cmd, + source: source, + visualKind: visualKind(for: cmd, source: source), + staysAtTail: false, + displayText: keyboardPoolDisplayText(for: cmd, source: source) + ) + + if occupiedSlots + candidate.span > maxPoolSlots { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Widget pool is full"), type: .error) + return false + } + + if isPad(candidate), poolItems.contains(where: isPad) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Cannot select two touchpad controls at the same time"), type: .error) + return false + } + + if let candidateDirectionGroup = directionPadGroup(for: cmd), + poolItems.contains(where: { existing in + directionPadGroup(for: existing.cmd) == candidateDirectionGroup + && ( + (isDirectionPadContainerCommand(cmd) && isDirectionPadSingleCommand(existing.cmd)) + || (isDirectionPadSingleCommand(cmd) && isDirectionPadContainerCommand(existing.cmd)) + ) + }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Direction pad cannot be combined with their inner button"), type: .error) + return false + } + + if let candidateTriggerGroup = triggerGroup(for: cmd), + poolItems.contains(where: { existing in + triggerGroup(for: existing.cmd) == candidateTriggerGroup && existing.cmd != cmd + }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Trigger button cannot be combined with trigger pad"), type: .error) + return false + } + + if directionPadPriorityCommands.contains(cmd), poolItems.contains(where: { gyroCommands.contains($0.cmd) }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Direction pad cannot be combined with gyro button"), type: .error) + return false + } + + if gyroCommands.contains(cmd), poolItems.contains(where: { directionPadPriorityCommands.contains($0.cmd) }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Gyro widgets cannot be combined with direction pad"), type: .error) + return false + } + + if cmd == "LSWHEEL", !poolItems.isEmpty { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "LS wheel must be placed alone"), type: .error) + return false + } + + if !poolItems.isEmpty, poolItems.contains(where: { $0.cmd == "LSWHEEL" }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "LsWheel cannot be combined with other widgets"), type: .error) + return false + } + + if source == .functional, !gyroCommands.contains(cmd) { + guard let option = functionalButtonOption(for: cmd) else { + resetTipMessage() + return false + } + + if poolItems.contains(where: isPad) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Functional button widgets cannot be combined with touchpad controls"), type: .error) + return false + } + + if poolItems.contains(where: { $0.source == .functional }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Functional button widgets cannot be combined with other functional buttons"), type: .error) + return false + } + + if !option.allowsKeyboardCombination, + poolItems.contains(where: { $0.source == .keyboard && !isPad($0) }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "This functional button cannot be combined with keyboard or mouse buttons"), type: .error) + return false + } + + if !option.allowsGamepadCombination, + poolItems.contains(where: { $0.source == .gamepad && !isPad($0) }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "This functional button cannot be combined with gamepad buttons"), type: .error) + return false + } + } + + if source == .keyboard || source == .gamepad { + if poolItems.contains(where: isPad) && poolItems.contains(where: { $0.source == .functional && !gyroCommands.contains($0.cmd) }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Functional button widgets cannot be combined with touchpad controls"), type: .error) + return false + } + + if let existingFunctionalItem = poolItems.first(where: { $0.source == .functional && !gyroCommands.contains($0.cmd) }), + let option = functionalButtonOption(for: existingFunctionalItem.cmd) { + if source == .keyboard && !option.allowsKeyboardCombination { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "This functional button cannot be combined with keyboard or mouse buttons"), type: .error) + return false + } + + if source == .gamepad && !option.allowsGamepadCombination { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "This functional button cannot be combined with gamepad buttons"), type: .error) + return false + } + } + + if isPad(candidate), + poolItems.contains(where: { $0.source == .functional && !gyroCommands.contains($0.cmd) }) { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Functional button widgets cannot be combined with touchpad controls"), type: .error) + return false + } + } + + resetTipMessage() + return true + } + + private func functionalButtonOption(for cmd: String) -> FunctionalButtonOption? { + functionalButtonOptions.first(where: { $0.cmd == cmd }) + } + + private var occupiedSlots: Int { + poolItems.reduce(0) { $0 + $1.span } + } + + private var gyroOptions: [GyroButtonOption] { + [ + GyroButtonOption( + cmd: "GYRO", + description: SwiftLocalizationHelper.localizedString(forKey: "Gyro on while activated") + ), + GyroButtonOption( + cmd: "GYROPAUSE", + description: SwiftLocalizationHelper.localizedString(forKey: "Gyro off while activated") + ) + ] + } + + private func handleGyroButtonTap() { + if let selectedGyroCommand { + removeCommand(selectedGyroCommand, source: .functional) + self.selectedGyroCommand = nil + } else { + showGyroPicker = true + } + } + + private func selectGyroOption(_ option: GyroButtonOption) { + guard canSelectCommand(option.cmd, source: .functional) else { + showGyroPicker = false + return + } + + appendCommand(option.cmd, source: .functional) + selectedGyroCommand = option.cmd + showGyroPicker = false + } + + private func handleFunctionalButtonSelection(_ option: FunctionalButtonOption) { + guard canSelectCommand(option.cmd, source: .functional) else { return } + appendCommand(option.cmd, source: .functional) + } + + private func handleFunctionalButtonDeselection(_ option: FunctionalButtonOption) { + removeCommand(option.cmd, source: .functional) + } + + private var createWidgetSheet: some View { + let isPhone = UIDevice.current.userInterfaceIdiom == .phone + let contentSpacing: CGFloat = isPhone ? 10 : 16 + let sectionSpacing: CGFloat = isPhone ? 7 : 10 + let fieldHeight: CGFloat = isPhone ? 30 : 38 + let actionSpacing: CGFloat = isPhone ? 8 : 12 + let actionHeight: CGFloat = isPhone ? 34 : 44 + let actionFontSize: CGFloat = isPhone ? 13 : 15 + let cardPadding: CGFloat = isPhone ? 12 : 18 + let cardMaxWidth: CGFloat = isPhone ? 320 : 420 + let cardCornerRadius: CGFloat = isPhone ? 22 : 28 + let horizontalInset: CGFloat = isPhone ? 14 : 24 + return ZStack { + Color.black.opacity(0.28) + .edgesIgnoringSafeArea(.all) + .contentShape(Rectangle()) + .onTapGesture { + setCreateWidgetSheetVisible(false) + } + + VStack(alignment: .leading, spacing: contentSpacing) { + VStack(alignment: .leading, spacing: sectionSpacing) { + if targetWidgetKind == .button { + if showsButtonLabelField { + createWidgetSection(title: SwiftLocalizationHelper.localizedString(forKey: "Button label")) { + TextField( + SwiftLocalizationHelper.localizedString(forKey: "Enter label(optional)"), + text: $widgetButtonLabel + ) + .padding(.horizontal, 12) + .frame(height: fieldHeight) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(0.78)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.88), lineWidth: 1) + ) + } + } + + if showsComboModeControl { + createWidgetSection(title: SwiftLocalizationHelper.localizedString(forKey: "Combination mode")) { + Picker("", selection: widgetComboModeBinding) { + ForEach(WidgetCreateComboMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(SegmentedPickerStyle()) + .disabled(isComboModeLocked) + .opacity(isComboModeLocked ? 0.45 : 1.0) + } + } + } + + if showsIntervalSlider { + createWidgetSection(title: "\(SwiftLocalizationHelper.localizedString(forKey: "Trigger interval")): \(effectiveTriggerIntervalValue) ms") { + Slider( + value: $widgetTriggerInterval, + in: 0...2000, + step: 1 + ) + .disabled(isShortcutMode) + .opacity(isShortcutMode ? 0.45 : 1.0) + } + } + + if targetWidgetKind == .button, showsShapeControl { + createWidgetSection(title: SwiftLocalizationHelper.localizedString(forKey: "Shape")) { + Picker("", selection: $widgetShape) { + ForEach(WidgetCreateShape.allCases) { shape in + Text(shape.title).tag(shape) + } + } + .pickerStyle(SegmentedPickerStyle()) + } + } + } + + HStack(spacing: actionSpacing) { + PoolActionChip( + title: SwiftLocalizationHelper.localizedString(forKey: "Cancel"), + isPrimary: false, + height: actionHeight, + fontSize: actionFontSize, + cornerRadius: 14 + ) { + setCreateWidgetSheetVisible(false) + } + + PoolActionChip( + title: pendingSubmissionAction.confirmationTitle, + isPrimary: true, + height: actionHeight, + fontSize: actionFontSize, + cornerRadius: 14 + ) { + submitWidgetPayload() + } + } + .padding(.top, isPhone ? 2 : 0) + } + .padding(cardPadding) + .frame(maxWidth: cardMaxWidth) + .background( + RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous) + .fill(Color(red: 0.94, green: 0.97, blue: 0.98).opacity(0.98)) + .overlay( + RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.96), lineWidth: 1.1) + ) + ) + .shadow(color: Color.black.opacity(0.12), radius: 24, x: 0, y: 12) + .padding(.horizontal, horizontalInset) + } + } + + private func createWidgetSection(title: String, @ViewBuilder content: () -> Content) -> some View { + let isPhone = UIDevice.current.userInterfaceIdiom == .phone + let titleFontSize: CGFloat = isPhone ? 11.5 : 13 + let sectionSpacing: CGFloat = isPhone ? 5 : 7 + let sectionHorizontalPadding: CGFloat = isPhone ? 9 : 12 + let sectionVerticalPadding: CGFloat = isPhone ? 6 : 10 + let sectionCornerRadius: CGFloat = isPhone ? 12 : 14 + + return VStack(alignment: .leading, spacing: sectionSpacing) { + Text(title) + .font(.system(size: titleFontSize, weight: .bold, design: .rounded)) + .foregroundColor(Color.black.opacity(0.64)) + + content() + .padding(.horizontal, sectionHorizontalPadding) + .padding(.vertical, sectionVerticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: sectionCornerRadius, style: .continuous) + .fill(Color.white.opacity(0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: sectionCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.94), lineWidth: 1) + ) + } + } + + private var firstPoolItem: WidgetPoolItem? { + poolItems.first + } + + private var targetWidgetKind: WidgetCreateTargetKind { + guard let firstPoolItem else { return .button } + switch firstPoolItem.visualKind { + case .gamepadPad, .keyboardPad: + return .pad + case .gamepadButton, .keyboardButton, .functionalButton: + return .button + } + } + + private var firstPoolItemIsFromGamepad: Bool { + firstPoolItem?.source == .gamepad + } + + private var isFunctionalOnlySelection: Bool { + firstPoolItem?.source == .functional + } + + private var buttonCommandCount: Int { + poolItems.filter { item in + switch item.visualKind { + case .gamepadButton, .keyboardButton, .functionalButton: + return true + case .gamepadPad, .keyboardPad: + return false + } + }.count + } + + private var hasPadCommand: Bool { + poolItems.contains(where: isPad) + } + + private var isShortcutMode: Bool { + effectiveWidgetComboMode == .shortcut + } + + private var selectedFunctionalButtonOption: FunctionalButtonOption? { + guard let functionalItem = poolItems.first(where: { $0.source == .functional && !gyroCommands.contains($0.cmd) }) else { + return nil + } + return functionalButtonOption(for: functionalItem.cmd) + } + + private var forcedComboMode: WidgetCreateComboMode? { + guard showsComboModeControl else { return nil } + if targetWidgetKind == .button && hasPadCommand { + return .skill + } + return selectedFunctionalButtonOption?.forcedComboMode + } + + private var effectiveWidgetComboMode: WidgetCreateComboMode { + forcedComboMode ?? widgetComboMode + } + + private var isComboModeLocked: Bool { + forcedComboMode != nil + } + + private var widgetComboModeBinding: Binding { + Binding( + get: { effectiveWidgetComboMode }, + set: { widgetComboMode = $0 } + ) + } + + private var showsComboModeControl: Bool { + guard targetWidgetKind == .button else { return false } + if let selectedFunctionalButtonOption { + return selectedFunctionalButtonOption.allowsSkillCombo || selectedFunctionalButtonOption.allowsShortcutCombo + } + return !isFunctionalOnlySelection + } + + private var showsShapeControl: Bool { + targetWidgetKind == .button + } + + private var showsButtonLabelField: Bool { + targetWidgetKind == .button + } + + private var showsIntervalSlider: Bool { + guard buttonCommandCount >= 2 else { return false } + if let selectedFunctionalButtonOption { + return selectedFunctionalButtonOption.allowsSkillCombo + } + return !isFunctionalOnlySelection + } + + private var effectiveTriggerIntervalValue: Int { + if effectiveWidgetComboMode == .shortcut && showsComboModeControl { + return 200 + } + return Int(widgetTriggerInterval.rounded()) + } + + private var widgetPayloadPreviewText: String { + let payload = makeWidgetPayload() + return "{\n \"cmdString\": \"\(payload["cmdString"] ?? "")\",\n \"buttonLabel\": \"\(payload["buttonLabel"] ?? "")\",\n \"shape\": \"\(payload["shape"] ?? "")\"\n}" + } + + private var shouldBypassCreateWidgetSheet: Bool { + poolItems.count == 1 || (targetWidgetKind == .pad && buttonCommandCount == 1) + } + + private func prepareCreateWidgetDefaults(for submissionAction: WidgetPickerSubmissionAction) { + if let selectedFunctionalButtonOption { + if selectedFunctionalButtonOption.allowsSkillCombo { + widgetComboMode = .skill + } else if selectedFunctionalButtonOption.allowsShortcutCombo { + widgetComboMode = .shortcut + } else { + widgetComboMode = .skill + } + } else { + widgetComboMode = .skill + } + if let forcedComboMode { + widgetComboMode = forcedComboMode + } + if isEditMode { + widgetComboMode = initialComboMode(from: initialCmdString) + widgetTriggerInterval = Double(initialTriggerInterval(from: initialCmdString)) + } else { + widgetTriggerInterval = 0 + } + if isEditMode, + submissionAction == .modify, + let initialButtonLabel, + targetWidgetKind == .button { + widgetButtonLabel = initialButtonLabel + } else { + widgetButtonLabel = "" + } + + if isEditMode, + targetWidgetKind == .button, + let initialShape = normalizedShape(from: initialShape), + initialShape != .largeSquare { + widgetShape = initialShape == .round ? .round : .square + } else { + widgetShape = firstPoolItemIsFromGamepad ? .round : .square + } + + pendingSubmissionAction = submissionAction + } + + private func presentCreateWidgetSheet(for submissionAction: WidgetPickerSubmissionAction) { + guard !poolItems.isEmpty else { + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Select at least one command before creating a widget"), type: .error) + return + } + + prepareCreateWidgetDefaults(for: submissionAction) + + if shouldBypassCreateWidgetSheet { + submitWidgetPayload() + return + } + + setCreateWidgetSheetVisible(true) + } + + private func submitWidgetPayload() { + let payload = makeWidgetPayload() + lastCreatedWidgetPayload = payload + setTipMessage(SwiftLocalizationHelper.localizedString(forKey: "Widget config generated")) + onWidgetCreated?(payload) + print("widgetPicker payload =", payload as NSDictionary) + setCreateWidgetSheetVisible(false) + } + + private func makeWidgetPayload() -> [String: String] { + [ + "cmdString": buildCmdString(), + "buttonLabel": targetWidgetKind == .button ? widgetButtonLabel : "", + "shape": targetWidgetKind == .button ? widgetShape.rawValue : "", + "pickerAction": pendingSubmissionAction.payloadValue + ] + } + + private func buildCmdString() -> String { + let joiner = effectiveWidgetComboMode == .shortcut && showsComboModeControl ? "+" : "-" + var cmdString = selectedCmds.joined(separator: joiner) + + let shouldAppendInterval = effectiveWidgetComboMode != .shortcut + && effectiveTriggerIntervalValue != 0 + && (hasPadCommand || effectiveWidgetComboMode == .skill) + if shouldAppendInterval { + cmdString += "-\(effectiveTriggerIntervalValue)MS" + } + + return cmdString + } + + private func setCreateWidgetSheetVisible(_ isVisible: Bool) { + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + showCreateWidgetSheet = isVisible + } + } + + private func applyInitialConfigurationIfNeeded() { + guard !didApplyInitialConfiguration else { return } + didApplyInitialConfiguration = true + guard let initialCmdString, !initialCmdString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + + let parsedCommands = parseCommands(from: initialCmdString) + guard !parsedCommands.isEmpty else { return } + + selectedCmds = parsedCommands.map(\.cmd) + poolItems = parsedCommands.map { descriptor in + WidgetPoolItem( + cmd: descriptor.cmd, + source: descriptor.source, + visualKind: visualKind(for: descriptor.cmd, source: descriptor.source), + staysAtTail: descriptor.isTailLocked, + displayText: descriptor.displayText + ) + } + selectedGyroCommand = parsedCommands.first(where: { gyroCommands.contains($0.cmd) })?.cmd + + if let preferredTab = parsedCommands.first(where: { $0.source != .functional || !gyroCommands.contains($0.cmd) })?.source { + switch preferredTab { + case .gamepad: + selectedTab = .gamepad + case .keyboard: + selectedTab = .keyboard + case .functional: + selectedTab = .functional + } + } else if parsedCommands.contains(where: { $0.source == .functional }) { + selectedTab = .functional + } + + updateTipMessageForCurrentPoolState() + } + + private func parseCommands(from cmdString: String) -> [(cmd: String, source: WidgetPoolSource, isTailLocked: Bool, displayText: String?)] { + let normalized = cmdString + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + + guard !normalized.isEmpty else { return [] } + + var commands: [String] + if normalized.contains("+") { + commands = normalized + .split(separator: "+") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + } else { + commands = normalized + .split(separator: "-") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + } + + if let last = commands.last, + last.range(of: #"^\d+MS$"#, options: .regularExpression) != nil { + commands.removeLast() + } + + var parsed: [(cmd: String, source: WidgetPoolSource, isTailLocked: Bool, displayText: String?)] = [] + for rawCommand in commands where !rawCommand.isEmpty { + let canonicalCommand = canonicalCommand(for: rawCommand) + guard let source = source(for: canonicalCommand) else { continue } + let displayText: String? + if source == .keyboard { + displayText = canonicalCommand + } else { + displayText = nil + } + + let item = WidgetPoolItem( + cmd: canonicalCommand, + source: source, + visualKind: visualKind(for: canonicalCommand, source: source), + staysAtTail: false, + displayText: displayText + ) + let isTailLocked = parsed.isEmpty ? false : isCombinableNonDirectionPad(item) + parsed.append((cmd: canonicalCommand, source: source, isTailLocked: isTailLocked, displayText: displayText)) + } + + return parsed + } + + private func source(for cmd: String) -> WidgetPoolSource? { + if gamepadCommands.contains(cmd) { + return .gamepad + } + if keyboardCommands.contains(cmd) { + return .keyboard + } + if functionalCommands.contains(cmd) { + return .functional + } + return nil + } + + private func canonicalCommand(for cmd: String) -> String { + switch cmd { + case "PICKPROFILE": + return "PICKPRFL" + case "WIDGETPROFILES": + return "PROFILES" + default: + return cmd + } + } + + private var gamepadCommands: Set { + [ + "OSCA", "OSCB", "OSCX", "OSCY", + "OSCSELECT", "OSCSTART", "OSCHOME", + "HOME", + "DPAD", + "OSCUP", "OSCDOWN", "OSCLEFT", "OSCRIGHT", + "LS", "LSWHEEL", "LSPAD", + "RS", "RSPAD", "RSVPAD", + "LB", "RB", + "LT", "LTPAD", + "RT", "RTPAD", + "DS4TOUCH", "DS4TCHBTN" + ] + } + + private var keyboardCommands: Set { + Set(CommandManager.keyboardButtonMappings.keys) + .union(Set(CommandManager.mouseButtonMappings.keys)) + .union(keyboardPadCommands) + } + + private var functionalCommands: Set { + Set(functionalButtonOptions.map(\.cmd)).union(gyroCommands) + } + + private enum InitialWidgetShape { + case round + case square + case largeSquare + } + + private func normalizedShape(from value: String?) -> InitialWidgetShape? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !value.isEmpty else { + return nil + } + + switch value { + case "r", "round": + return .round + case "s", "square": + return .square + case "largesquare": + return .largeSquare + default: + return nil + } + } + + private func initialComboMode(from cmdString: String?) -> WidgetCreateComboMode { + let normalized = cmdString? + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() ?? "" + + if normalized.contains("+") { + return .shortcut + } + return .skill + } + + private func initialTriggerInterval(from cmdString: String?) -> Int { + let normalized = cmdString? + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() ?? "" + + guard let match = normalized.range(of: #"(\d+)MS$"#, options: .regularExpression) else { + return 0 + } + + let suffix = String(normalized[match]) + return Int(suffix.replacingOccurrences(of: "MS", with: "")) ?? 0 + } +} + +@available(iOS 13.0, *) +struct FunctionalButtonCollectionView: View { + let items: [FunctionalButtonOption] + let isSelected: (String) -> Bool + let onSelect: (FunctionalButtonOption) -> Void + let onDeselect: (FunctionalButtonOption) -> Void + + private let desiredColumns: CGFloat = 7 + private let horizontalSpacing: CGFloat = 8 + private let verticalSpacing: CGFloat = 8 + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + FunctionalButtonFlowLayout( + items: items, + desiredColumns: desiredColumns, + horizontalSpacing: horizontalSpacing, + verticalSpacing: verticalSpacing, + isSelected: isSelected, + onSelect: onSelect, + onDeselect: onDeselect + ) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +@available(iOS 13.0, *) +struct FunctionalButtonFlowLayout: View { + let items: [FunctionalButtonOption] + let desiredColumns: CGFloat + let horizontalSpacing: CGFloat + let verticalSpacing: CGFloat + let isSelected: (String) -> Bool + let onSelect: (FunctionalButtonOption) -> Void + let onDeselect: (FunctionalButtonOption) -> Void + + @SwiftUI.State private var contentHeight: CGFloat = .zero + + var body: some View { + GeometryReader { proxy in + generateContent(in: proxy) + } + .frame(height: contentHeight) + } + + private func generateContent(in proxy: GeometryProxy) -> some View { + let buttonSize = max( + 44, + floor((max(proxy.size.width, 1) - horizontalSpacing * (desiredColumns - 1)) / desiredColumns) + ) + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + + return ZStack(alignment: .topLeading) { + ForEach(items) { item in + FunctionalCollectionButton( + item: item, + isSelected: isSelected(item.cmd), + width: buttonSize + ) { + if isSelected(item.cmd) { + onDeselect(item) + } else { + onSelect(item) + } + } + .alignmentGuide(.leading) { dimensions in + if currentX + dimensions.width > proxy.size.width, currentX > 0 { + currentX = 0 + currentY += dimensions.height + verticalSpacing + } + + let result = currentX + currentX += dimensions.width + horizontalSpacing + return -result + } + .alignmentGuide(.top) { _ in + let result = currentY + if item.id == items.last?.id { + currentX = 0 + currentY = 0 + } + return -result + } + } + } + .background( + GeometryReader { geometry in + Color.clear.preference(key: FunctionalFlowHeightPreferenceKey.self, value: geometry.size.height) + } + ) + .onPreferenceChange(FunctionalFlowHeightPreferenceKey.self) { contentHeight = $0 } + } +} + +@available(iOS 13.0, *) +struct FunctionalCollectionButton: View { + let item: FunctionalButtonOption + let isSelected: Bool + let width: CGFloat + let action: () -> Void + + private var labelFontSize: CGFloat { + UIDevice.current.userInterfaceIdiom == .phone ? 8 : 12 + } + + var body: some View { + Button(action: action) { + Text(item.label) + .font(.system(size: labelFontSize, weight: .bold, design: .rounded)) + .foregroundColor(Color.black.opacity(0.70)) + .minimumScaleFactor(0.7) + .lineLimit(2) + .multilineTextAlignment(.center) + .padding(.horizontal, 4) + .frame(width: width, height: width) + .background(backgroundFill) + .overlay(borderOverlay) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 4) + } + .buttonStyle(PlainButtonStyle()) + } + + private var backgroundFill: some View { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: isSelected + ? [ + Color(red: 1.00, green: 0.96, blue: 0.74), + Color(red: 0.95, green: 0.78, blue: 0.32) + ] + : [ + Color.white.opacity(0.82), + Color.white.opacity(0.58) + ] + ), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + + private var borderOverlay: some View { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke( + isSelected ? Color.orange.opacity(0.75) : Color.white.opacity(0.76), + lineWidth: 1.4 + ) + } +} + +private struct FunctionalFlowHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = .zero + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +@available(iOS 13.0, *) +struct PoolActionChip: View { + let title: String + let isPrimary: Bool + let height: CGFloat + let fontSize: CGFloat + let cornerRadius: CGFloat + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundColor(isPrimary ? .white : Color.black.opacity(0.66)) + .frame(maxWidth: .infinity) + .frame(height: height) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill( + isPrimary + ? LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.18, green: 0.61, blue: 0.67), + Color(red: 0.14, green: 0.46, blue: 0.52) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + : LinearGradient( + gradient: Gradient(colors: [ + Color.white.opacity(0.82), + Color.white.opacity(0.58) + ]), + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.white.opacity(isPrimary ? 0.22 : 0.76), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} + +@available(iOS 13.0, *) +struct GyroButtonPickerOverlay: View { + let options: [GyroButtonOption] + let onSelect: (GyroButtonOption) -> Void + let onCancel: () -> Void + + var body: some View { + ZStack { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + onCancel() + } + + VStack(spacing: 14) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Select Gyro Button")) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundColor(.white) + + Text(SwiftLocalizationHelper.localizedString(forKey: "Choose a gyro control style")) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(Color.white.opacity(0.78)) + .multilineTextAlignment(.center) + + VStack(spacing: 10) { + ForEach(options) { option in + Button(action: { + onSelect(option) + }) { + HStack(spacing: 12) { + Text(option.description) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + + Spacer() + + Text(option.cmd) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundColor(Color.white.opacity(0.72)) + } + .padding(.horizontal, 14) + .frame(height: 44) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.white.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + + Button(action: { + onCancel() + }) { + Text(SwiftLocalizationHelper.localizedString(forKey: "Cancel")) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundColor(Color.white.opacity(0.9)) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.black.opacity(0.16)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + ) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(18) + .frame(maxWidth: 320) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.15, green: 0.46, blue: 0.50), + Color(red: 0.11, green: 0.34, blue: 0.39) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 1.2) + ) + ) + .shadow(color: Color.black.opacity(0.22), radius: 20, x: 0, y: 10) + .padding(.horizontal, 24) + } + } +} + +@available(iOS 13.0, *) +struct WidgetPoolGridView: View { + let items: [WidgetPoolItem] + let onItemTap: (WidgetPoolItem) -> Void + let spacing: CGFloat + let contentInset: CGFloat + + private let columns = 4 + + var body: some View { + GeometryReader { proxy in + let width = proxy.size.width + let height = proxy.size.height + let availableSide = max(min(width, height) - contentInset * 2, 1) + let cellSize = (availableSide - CGFloat(columns - 1) * spacing) / CGFloat(columns) + let gridSide = cellSize * CGFloat(columns) + spacing * CGFloat(columns - 1) + let gridOriginX = (width - gridSide) * 0.5 + let gridOriginY = (height - gridSide) * 0.5 + let placements = makePlacements(for: items, columns: columns) + + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.83, green: 0.90, blue: 0.93), + Color(red: 0.72, green: 0.82, blue: 0.86) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color.white.opacity(0.75), lineWidth: 1.2) + ) + + ForEach(0.. some View { + Group { + if item.visualKind == .gamepadButton { + Circle() + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 1.00, green: 0.96, blue: 0.74), + Color(red: 0.95, green: 0.78, blue: 0.32) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + Circle() + .stroke(Color.orange.opacity(0.75), lineWidth: 1.4) + ) + .overlay( + Text(item.displayCmd) + .font(.system(size: max(11, cellSize * 0.18), weight: .bold, design: .rounded)) + .foregroundColor(Color.black.opacity(0.70)) + .minimumScaleFactor(0.6) + ) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 4) + } else if item.visualKind == .keyboardButton { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 1.00, green: 0.96, blue: 0.74), + Color(red: 0.95, green: 0.78, blue: 0.32) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.orange.opacity(0.75), lineWidth: 1.4) + ) + .overlay( + Text(item.displayCmd) + .font(.system(size: max(11, cellSize * 0.18), weight: .bold, design: .rounded)) + .foregroundColor(Color.black.opacity(0.70)) + .minimumScaleFactor(0.6) + .lineLimit(2) + .padding(.horizontal, 8) + ) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 4) + } else if item.visualKind == .functionalButton { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 1.00, green: 0.96, blue: 0.74), + Color(red: 0.95, green: 0.78, blue: 0.32) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.orange.opacity(0.75), lineWidth: 1.4) + ) + .overlay( + Text(item.displayCmd) + .font(.system(size: max(11, cellSize * 0.17), weight: .semibold, design: .rounded)) + .foregroundColor(Color.black.opacity(0.72)) + .minimumScaleFactor(0.7) + .lineLimit(2) + .padding(.horizontal, 8) + ) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 4) + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + PoolPadHighlightStyle.fillTop, + PoolPadHighlightStyle.fillBottom + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(PoolPadHighlightStyle.stroke, lineWidth: 1.2) + ) + .overlay( + Text(item.displayCmd) + .font(.system(size: max(11, cellSize * 0.17), weight: .semibold, design: .rounded)) + .foregroundColor(Color.black.opacity(0.72)) + .minimumScaleFactor(0.7) + .lineLimit(2) + .padding(.horizontal, 8) + ) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 4) + } + } + } + + private func makePlacements(for items: [WidgetPoolItem], columns: Int) -> [WidgetPoolPlacement] { + var placements: [WidgetPoolPlacement] = [] + var row = 0 + var column = 0 + + for item in items { + let span = min(item.span, columns) + + if column + span > columns { + row += 1 + column = 0 + } + + placements.append( + WidgetPoolPlacement( + id: item.id, + item: item, + row: row, + column: column + ) + ) + + column += span + if column >= columns { + row += 1 + column = 0 + } + } + + return placements + } +} + +@available(iOS 13.0, *) +struct WidgetPickerView_Previews: PreviewProvider { + static var previews: some View { + WidgetPickerView() + .previewLayout(.fixed(width: 1400, height: 860)) + } +} diff --git a/VoidLinkSwiftUI/WidgetPickerViewController.swift b/VoidLinkSwiftUI/WidgetPickerViewController.swift new file mode 100644 index 00000000..b3618cb6 --- /dev/null +++ b/VoidLinkSwiftUI/WidgetPickerViewController.swift @@ -0,0 +1,76 @@ +import SwiftUI +import UIKit + +@available(iOS 13.0, *) +@objc public protocol WidgetPickerViewControllerDelegate: AnyObject { + func widgetPickerViewController(_ controller: WidgetPickerViewController, didCreateWidget payload: NSDictionary) + @objc optional func widgetPickerViewControllerDidCancel(_ controller: WidgetPickerViewController) +} + +@available(iOS 13.0, *) +@objcMembers +public final class WidgetPickerViewController: UIViewController { + public weak var delegate: WidgetPickerViewControllerDelegate? + public var isEditMode: Bool = false + public var initialCmdString: String? + public var initialButtonLabel: String? + public var initialShape: String? + public var tabIdentifiers: [String] = [] + public var initialTabIdentifier: String? + + private var hostingViewController: UIHostingController? + + public override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + let rootView = WidgetPickerView( + isEditMode: isEditMode, + initialCmdString: initialCmdString, + initialButtonLabel: initialButtonLabel, + initialShape: initialShape, + availableTabs: resolvedTabs(), + preferredInitialTab: resolvedInitialTab(), + onWidgetCreated: { [weak self] payload in + guard let self else { return } + self.delegate?.widgetPickerViewController(self, didCreateWidget: payload as NSDictionary) + self.dismiss(animated: true) + }, + onCloseRequested: { [weak self] in + self?.dismiss(animated: true) + } + ) + let hostingViewController = UIHostingController(rootView: rootView) + self.hostingViewController = hostingViewController + + addChild(hostingViewController) + hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingViewController.view) + hostingViewController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + hostingViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + hostingViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + preferredContentSize = CGSize(width: 1120, height: 760) + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: false) + } + + private func resolvedTabs() -> [WidgetPickerTab] { + let tabs = tabIdentifiers.compactMap(WidgetPickerTab.init(identifier:)) + return tabs.isEmpty ? WidgetPickerTab.allCases : tabs + } + + private func resolvedInitialTab() -> WidgetPickerTab? { + guard let initialTabIdentifier else { return nil } + return WidgetPickerTab(identifier: initialTabIdentifier) + } +}