diff --git a/VoidLink/Input/CommandManager.swift b/VoidLink/Input/CommandManager.swift index c1665acc..1c55245f 100644 --- a/VoidLink/Input/CommandManager.swift +++ b/VoidLink/Input/CommandManager.swift @@ -19,12 +19,14 @@ import UIKit // MARK: - Properties + @objc public private(set) var identifier: String @objc var cmdString: String @objc var alias: String // MARK: - Initialization init(cmdString: String, alias: String) { + self.identifier = UUID().uuidString self.cmdString = cmdString self.alias = alias } @@ -36,11 +38,13 @@ import UIKit let alias = coder.decodeObject(of: NSString.self, forKey: "alias") as String? else { return nil } + self.identifier = coder.decodeObject(of: NSString.self, forKey: "identifier") as String? ?? UUID().uuidString self.cmdString = cmdString self.alias = alias } public func encode(with coder: NSCoder) { + coder.encode(identifier, forKey: "identifier") coder.encode(cmdString, forKey: "keyboardCmdString") coder.encode(alias, forKey: "alias") } @@ -50,6 +54,29 @@ import UIKit // Define the CommandManager class @objc public class CommandManager: NSObject { @objc public static let shared = CommandManager() + + private static let defaultCommandAliasMigrationMap: [String: String] = [ + "任务管理器(Task Manager)": "=taskManager", + "Task Manager": "=taskManager", + "N卡截图(Nvidia Screenshot)": "=nvidiaShot", + "Nvidia Screenshot": "=nvidiaShot", + "N卡录屏(Nvidia Screen Recording)": "=nvidiaRec", + "Nvidia Screen Recording": "=nvidiaRec", + "关闭窗口(ALT+F4)": "=closeWindow", + "Close Window": "=closeWindow", + "全选(Select All)": "=selectAll", + "Select All": "=selectAll", + "复制(Copy)": "=copy", + "Copy": "=copy", + "粘贴(Paste)": "=paste", + "Paste": "=paste", + "切换桌面(Switch to Desktop)": "=switchDesktop", + "Switch to Desktop": "=switchDesktop", + "多显模式(Project)": "=projectDisplay", + "Project": "=projectDisplay", + "Xbox Game Bar": "=xboxGameBar", + "Steam Overlay": "=steamOverlay", + ] @objc public static let mouseButtonMappings: [String: Int32] = [ "M_LEFT" : BUTTON_LEFT, @@ -463,17 +490,17 @@ import UIKit RemoteCommand(cmdString: "WIN", alias: "WIN"), RemoteCommand(cmdString: "F11", alias: "F11"), RemoteCommand(cmdString: "ESC", alias: "ESC"), - RemoteCommand(cmdString: "CTRL+SHIFT+ESC", alias: "任务管理器(Task Manager)"), - RemoteCommand(cmdString: "ALT+F1", alias: "N卡截图(Nvidia Screenshot)"), - RemoteCommand(cmdString: "ALT+F9", alias: "N卡录屏(Nvidia Screen Recording)"), - RemoteCommand(cmdString: "ALT+F4", alias: "关闭窗口(ALT+F4)"), - RemoteCommand(cmdString: "CTRL+A", alias: "全选(Select All)"), - RemoteCommand(cmdString: "CTRL+C", alias: "复制(Copy)"), - RemoteCommand(cmdString: "CTRL+V", alias: "粘贴(Paste)"), - RemoteCommand(cmdString: "WIN+D", alias: "切换桌面(Switch to Desktop)"), - RemoteCommand(cmdString: "WIN+P", alias: "多显模式(Project)"), - RemoteCommand(cmdString: "WIN+G", alias: "Xbox Game Bar"), - RemoteCommand(cmdString: "SHIFT+TAB", alias: "Steam Overlay"), + RemoteCommand(cmdString: "CTRL+SHIFT+ESC", alias: "=taskManager"), + RemoteCommand(cmdString: "ALT+F1", alias: "=nvidiaShot"), + RemoteCommand(cmdString: "ALT+F9", alias: "=nvidiaRec"), + RemoteCommand(cmdString: "ALT+F4", alias: "=closeWindow"), + RemoteCommand(cmdString: "CTRL+A", alias: "=selectAll"), + RemoteCommand(cmdString: "CTRL+C", alias: "=copy"), + RemoteCommand(cmdString: "CTRL+V", alias: "=paste"), + RemoteCommand(cmdString: "WIN+D", alias: "=switchDesktop"), + RemoteCommand(cmdString: "WIN+P", alias: "=projectDisplay"), + RemoteCommand(cmdString: "WIN+G", alias: "=xboxGameBar"), + RemoteCommand(cmdString: "SHIFT+TAB", alias: "=steamOverlay"), ] let data = try? NSKeyedArchiver.archivedData(withRootObject: defaultCommands, requiringSecureCoding: false) @@ -633,6 +660,29 @@ import UIKit @objc public func getAllCommands() -> [RemoteCommand] { return commands } + + public func reorderCommands(withIdentifiers identifiers: [String]) { + guard !identifiers.isEmpty else { return } + + var remainingCommandsByID = Dictionary(grouping: commands, by: { $0.identifier }) + var reorderedCommands: [RemoteCommand] = [] + + for identifier in identifiers { + guard var commandGroup = remainingCommandsByID[identifier], + !commandGroup.isEmpty else { + continue + } + reorderedCommands.append(commandGroup.removeFirst()) + remainingCommandsByID[identifier] = commandGroup + } + + for command in commands where !reorderedCommands.contains(where: { $0 === command }) { + reorderedCommands.append(command) + } + + commands = reorderedCommands + saveCommands() + } private func loadCommands() { if let savedCommandsData = UserDefaults.standard.data(forKey: "savedCommands") { @@ -642,6 +692,7 @@ import UIKit // Assign the unarchived commands to your property print(" Assign the unarchived commands to your property ") commands = savedCommands + migrateDefaultCommandAliasesIfNeeded() } else { // Handle the case where the data could not be unarchived into the expected type print("Data could not be unarchived into [RemoteCommand]") @@ -658,6 +709,21 @@ import UIKit UserDefaults.standard.set(data, forKey: "savedCommands") } } + + private func migrateDefaultCommandAliasesIfNeeded() { + var didMigrate = false + for command in commands { + guard let migratedAlias = Self.defaultCommandAliasMigrationMap[command.alias] else { + continue + } + command.alias = migratedAlias + didMigrate = true + } + + if didMigrate { + saveCommands() + } + } @objc public func sendAutoReleaseComboCommand(cmdStrings: [String]?, delay: TimeInterval = 0.2, index: Int = 0, pressOnly: Bool = false, releaseOnly:Bool = false) { // we need a large delay for WAN streaming // 如果已处理完所有按键,则开始释放按键 diff --git a/VoidLink/Input/ControllerNavigator.swift b/VoidLink/Input/ControllerNavigator.swift index b08bd4eb..fb71c82a 100644 --- a/VoidLink/Input/ControllerNavigator.swift +++ b/VoidLink/Input/ControllerNavigator.swift @@ -23,6 +23,7 @@ final class ControllerNavigationElement: NSObject { private var controllerNavigationSelectedIndexPathKey: UInt8 = 0 private var controllerNavigationPersistTokenKey: UInt8 = 0 +private var controllerNavigationHighlightGenerationKey: UInt8 = 0 private let settingsControllerNavigationHighlightedIdentifierKey = "SettingsControllerNavigationHighlightedIdentifier" private let hostControllerNavigationHighlightedUUIDKey = "HostControllerNavigationHighlightedUUID" @@ -50,6 +51,27 @@ private final class ControllerMouseDisplayLinkTarget: NSObject { func restoreControllerNavigationHighlightAfterSettingsModeSwitch() } +protocol ControllerNavigationHighlightTargetProviding: AnyObject { + var controllerNavigationHighlightTargetView: UIView { get } + func controllerNavigationHighlightDidApply() + func controllerNavigationHighlightDidClear() +} + +extension ControllerNavigationHighlightTargetProviding { + func controllerNavigationHighlightDidApply() { + } + + func controllerNavigationHighlightDidClear() { + } +} + +@available(iOS 13.0, *) +protocol ControllerCollectionNavigationDelegate: ControllerUINavigationDelegate where Self: UIViewController { + var controllerNavigationCollectionView: UICollectionView { get } + func controllerNavigationCurrentIndexPathForControllerNavigator() -> IndexPath? + func clearCollectionControllerNavigationHighlightForControllerNavigator() +} + @available(iOS 13.0, *) final class ControllerNavigator: NSObject { typealias Handler = (_ buttonDict: NSDictionary, _ gamepad: GCExtendedGamepad, _ element: GCControllerElement) -> Void @@ -416,7 +438,7 @@ final class ControllerNavigator: NSObject { mainFrameVC.hostCollectionVC?.clearCollectionControllerNavigationHighlightForControllerNavigator() } else { (uiNavigationDelegate as? SettingsViewController)?.clearControllerNavigationHighlightForControllerNavigator() - (uiNavigationDelegate as? UICollectionViewController)?.clearCollectionControllerNavigationHighlightForControllerNavigator() + (uiNavigationDelegate as? ControllerCollectionNavigationDelegate)?.clearCollectionControllerNavigationHighlightForControllerNavigator() } controllerNavigationHighlightedView = nil @@ -486,6 +508,12 @@ final class ControllerNavigator: NSObject { } case .exit: exit(0) + case .shortcuts: + DispatchQueue.main.async { + radialMenuView?.dismiss() + radialMenuView = nil + StreamFrameViewController.sharedInstance().bringUpToolboxMenu() + } default: break } @@ -516,7 +544,7 @@ final class ControllerNavigator: NSObject { if radialMenuState == .moreOptions { if mainFrameVC.isStreaming() { RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "Game Profiles".localized, subtitle: "", symbol:PublicUtils.iOS18Available ? "gamecontroller.circle" : "gamecontroller.fill", item: .gameProfiles)) - RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "=shortcuts".localized, subtitle: "", symbol: "apple.terminal", item: .mouse)) + RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "=shortcuts".localized, subtitle: "", symbol: "apple.terminal", item: .shortcuts)) } else { if !mainFrameVC.isInAppView(), !mainFrameVC.settingsViewExpanded {RadialMenuOverlayView.menuSectors.append(RadialMenuSector(title: "About".localized, subtitle: "", symbol: "questionmark.circle", item: .aboutView))} @@ -668,7 +696,7 @@ final class ControllerNavigator: NSObject { - if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || mainFrameVC.settingsExpandedInStreamView { + if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || mainFrameVC.settingsExpandedInStreamView || uiNavigationDelegate is ToolboxViewController { let verticalNavigationAxis: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .leftStickY : .rightStickY ControllerUtil.listenPrimaryControllerStickAxis(verticalNavigationAxis, threshold: 0.6) {state in GamepadNavigationIllustrationHud.updateActionState(for: verticalNavigationAxis, isInAction: state != .orderedSame) @@ -679,7 +707,7 @@ final class ControllerNavigator: NSObject { if uiNavigationDelegate is SettingsViewController { navigateContinuously(forward: true) } - if uiNavigationDelegate is UICollectionViewController { + if uiNavigationDelegate is ControllerCollectionNavigationDelegate { navigateContinuously(downward: true) } } @@ -688,7 +716,7 @@ final class ControllerNavigator: NSObject { if uiNavigationDelegate is SettingsViewController { navigateContinuously(forward: false) } - if uiNavigationDelegate is UICollectionViewController { + if uiNavigationDelegate is ControllerCollectionNavigationDelegate { navigateContinuously(downward: false) } } @@ -698,7 +726,7 @@ final class ControllerNavigator: NSObject { } } - if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() { + if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || uiNavigationDelegate is ToolboxViewController{ let horizontalNavigationAxis: ControllerElement = ControllerNavigator.radialMenuButtonPosition == .right ? .leftStickX : .rightStickX ControllerUtil.listenPrimaryControllerStickAxis(horizontalNavigationAxis, threshold: 0.6) {state in GamepadNavigationIllustrationHud.updateActionState(for: horizontalNavigationAxis, isInAction: state != .orderedSame) @@ -706,13 +734,13 @@ final class ControllerNavigator: NSObject { switch state { case .orderedAscending: if navigationTimer?.isRunning() != true { - if uiNavigationDelegate is UICollectionViewController { + if uiNavigationDelegate is ControllerCollectionNavigationDelegate { navigateContinuously(forward: false) } } case .orderedDescending: if navigationTimer?.isRunning() != true { - if uiNavigationDelegate is UICollectionViewController { + if uiNavigationDelegate is ControllerCollectionNavigationDelegate { navigateContinuously(forward: true) } } @@ -722,7 +750,7 @@ final class ControllerNavigator: NSObject { } } - if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || mainFrameVC.settingsExpandedInStreamView || uiNavigationDelegate is UIAlertController || uiNavigationDelegate is OSCProfilesTableViewController { + if let uiNavigationDelegate = uiNavigationDelegate, !mainFrameVC.isStreaming() || mainFrameVC.settingsExpandedInStreamView || uiNavigationDelegate is UIAlertController || uiNavigationDelegate is OSCProfilesTableViewController || uiNavigationDelegate is ToolboxViewController { let navigations = uiNavigationDelegate.getNavigationElements() let buttonNavigations = navigations.filter({$0.control.type == .button}) // let stickNavigations = navigations.filter({$0.control.type == .stick || $0.control.type == .stickAxis}) @@ -764,7 +792,7 @@ final class ControllerNavigator: NSObject { @available(iOS 13.0, *) extension SettingsViewController: ControllerUINavigationDelegate { - func persistControllerNavigationHighlight() { + @objc func persistControllerNavigationHighlight() { guard let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, let identifier = controllerNavigationPersistenceIdentifier(for: highlightedView) else { return @@ -773,20 +801,20 @@ extension SettingsViewController: ControllerUINavigationDelegate { UserDefaults.standard.set(identifier, forKey: settingsControllerNavigationHighlightedIdentifierKey) } - func restoreControllerNavigationHighlight() { + @objc func restoreControllerNavigationHighlight() { guard ControllerNavigator.enabled, ControllerUtil.primaryGCController != nil else {return} PublicUtils.runOnMain { [weak self] in self?.restoreControllerNavigationHighlightOnMain() } } - func restoreControllerNavigationHighlightAfterSettingsModeSwitch() { + @objc func restoreControllerNavigationHighlightAfterSettingsModeSwitch() { PublicUtils.runOnMain { [weak self] in self?.restoreControllerNavigationHighlightAfterSettingsModeSwitchOnMain() } } - func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { + @objc func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { let isHighlightingUIStack = ControllerNavigator.controllerNavigationHighlightedView is UIStackView if navigation.action == "holdToReorder", isHighlightingUIStack { @@ -1427,7 +1455,8 @@ extension SettingsViewController: ControllerUINavigationDelegate { } } -extension UICollectionViewController { +@available(iOS 13.0, *) +extension ControllerCollectionNavigationDelegate { var controllerNavigationSelectedIndexPath: IndexPath? { get { objc_getAssociatedObject(self, &controllerNavigationSelectedIndexPathKey) as? IndexPath @@ -1446,16 +1475,49 @@ extension UICollectionViewController { } } + private var controllerNavigationHighlightGeneration: Int { + get { + objc_getAssociatedObject(self, &controllerNavigationHighlightGenerationKey) as? Int ?? 0 + } + set { + objc_setAssociatedObject(self, &controllerNavigationHighlightGenerationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + var controllerNavigationCollectionView: UICollectionView { + guard let collectionViewController = self as? UICollectionViewController else { + assertionFailure("ControllerCollectionNavigationDelegate adopters must provide controllerNavigationCollectionView") + return UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + } + + return collectionViewController.collectionView + } + func applyControllerNavigationHighlight(to cell: UICollectionViewCell, highlighted: Bool) { clearControllerNavigationHighlightBorder(in: cell) guard highlighted else { return } let highlightedView = controllerNavigationHighlightTargetView(for: cell) - highlightedView.layer.borderWidth = (self is HostCollectionViewController) ? 3 : 5 let isDarkTheme = ThemeManager.userInterfaceStyle() == .dark - highlightedView.layer.borderColor = ThemeManager.appPrimaryColor.withAlphaComponent(isDarkTheme ? 0.85 : 0.93).cgColor + if self is ToolboxViewController { + highlightedView.layer.borderColor = UIColor.systemOrange.withAlphaComponent(isDarkTheme ? 0.8 : 0.97).cgColor + highlightedView.layer.borderWidth = 3 + } + else { + highlightedView.layer.borderColor = ThemeManager.appPrimaryColor.withAlphaComponent(isDarkTheme ? 0.85 : 0.93).cgColor + highlightedView.layer.borderWidth = (self is HostCollectionViewController) ? 3 : 5 + } + (cell as? ControllerNavigationHighlightTargetProviding)?.controllerNavigationHighlightDidApply() } private func clearControllerNavigationHighlightBorder(in cell: UICollectionViewCell) { + if let highlightProvider = cell as? ControllerNavigationHighlightTargetProviding { + let targetView = highlightProvider.controllerNavigationHighlightTargetView + targetView.layer.borderWidth = 0 + targetView.layer.borderColor = nil + highlightProvider.controllerNavigationHighlightDidClear() + return + } + for view in [cell, cell.contentView] + cell.contentView.subviews + cell.subviews { view.layer.borderWidth = 0 view.layer.borderColor = nil @@ -1463,6 +1525,10 @@ extension UICollectionViewController { } private func controllerNavigationHighlightTargetView(for cell: UICollectionViewCell) -> UIView { + if let highlightProvider = cell as? ControllerNavigationHighlightTargetProviding { + return highlightProvider.controllerNavigationHighlightTargetView + } + if let contentView = controllerNavigationContentView(in: cell) { return contentView } @@ -1486,8 +1552,41 @@ extension UICollectionViewController { } @available(iOS 13.0, *) -extension UICollectionViewController: ControllerUINavigationDelegate { - func persistControllerNavigationHighlight() { +extension UICollectionViewController: ControllerCollectionNavigationDelegate { + @objc func persistControllerNavigationHighlight() { + controllerNavigationPersistCollectionHighlight() + } + + @objc func restoreControllerNavigationHighlight() { + controllerNavigationRestoreCollectionHighlight() + } + + @objc func restoreControllerNavigationHighlightAfterSettingsModeSwitch() { + } + + @objc func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) { + } + + @objc func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { + controllerNavigationPerformDefaultButtonAction(pressed: pressed, from: navigation) + } + + @objc func getNavigationElements() -> [ControllerNavigationElement] { + controllerNavigationDefaultNavigationElements() + } + + @objc func navigateByController(forward: Bool) { + controllerNavigationNavigateCollection(forward: forward) + } + + @objc func navigateByController(downward: Bool) { + controllerNavigationNavigateCollection(downward: downward) + } +} + +@available(iOS 13.0, *) +extension ControllerCollectionNavigationDelegate { + func controllerNavigationPersistCollectionHighlight() { if let hostCollectionVC = self as? HostCollectionViewController { persistHostControllerNavigationHighlight(in: hostCollectionVC) return @@ -1498,7 +1597,7 @@ extension UICollectionViewController: ControllerUINavigationDelegate { } } - func restoreControllerNavigationHighlight() { + func controllerNavigationRestoreCollectionHighlight() { guard ControllerNavigator.enabled, ControllerUtil.primaryGCController != nil else {return} PublicUtils.runOnMain { [weak self] in @@ -1515,18 +1614,15 @@ extension UICollectionViewController: ControllerUINavigationDelegate { } } - func restoreControllerNavigationHighlightAfterSettingsModeSwitch() { - } - private func currentControllerNavigationCell() -> UICollectionViewCell? { guard let selectedIndexPath = controllerNavigationSelectedIndexPath, - selectedIndexPath.section < collectionView.numberOfSections, + selectedIndexPath.section < controllerNavigationCollectionView.numberOfSections, selectedIndexPath.item >= 0, - selectedIndexPath.item < collectionView.numberOfItems(inSection: selectedIndexPath.section) else { + selectedIndexPath.item < controllerNavigationCollectionView.numberOfItems(inSection: selectedIndexPath.section) else { return nil } - return collectionView.cellForItem(at: selectedIndexPath) + return controllerNavigationCollectionView.cellForItem(at: selectedIndexPath) } private func currentControllerNavigationContentView() -> UIView? { @@ -1534,7 +1630,7 @@ extension UICollectionViewController: ControllerUINavigationDelegate { return controllerNavigationContentView(in: cell) } - func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { + func controllerNavigationPerformDefaultButtonAction(pressed: Bool, from navigation: ControllerNavigationElement) { PublicUtils.runOnMain { [weak self] in guard let self else { return } @@ -1570,10 +1666,7 @@ extension UICollectionViewController: ControllerUINavigationDelegate { } } - func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) { - } - - func getNavigationElements() -> [ControllerNavigationElement] { + func controllerNavigationDefaultNavigationElements() -> [ControllerNavigationElement] { var elements: [ControllerNavigationElement] = [] elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButton, action: "radialMenu")) elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .rightStick : .leftStick, action: "focusNavigation")) @@ -1584,13 +1677,13 @@ extension UICollectionViewController: ControllerUINavigationDelegate { return elements } - func navigateByController(forward: Bool) { + func controllerNavigationNavigateCollection(forward: Bool) { guard hasNoPresentedVC else { return } performControllerNavigationSelectionMove(horizontalOffset: forward ? 1 : -1) scheduleControllerNavigationHighlightPersistenceWhenStickSettles() } - func navigateByController(downward: Bool) { + func controllerNavigationNavigateCollection(downward: Bool) { guard hasNoPresentedVC else { return } performControllerNavigationSelectionMove(verticalOffset: downward ? 1 : -1) scheduleControllerNavigationHighlightPersistenceWhenStickSettles() @@ -1648,7 +1741,7 @@ extension UICollectionViewController: ControllerUINavigationDelegate { continue } - highlightControllerNavigationItem(at: IndexPath(item: index, section: 0)) + controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: index, section: 0)) return } @@ -1674,7 +1767,7 @@ extension UICollectionViewController: ControllerUINavigationDelegate { } for (index, app) in mainFrameVC.sortedAppList.enumerated() where app.id == appID { - highlightControllerNavigationItem(at: IndexPath(item: index, section: 0)) + controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: index, section: 0)) return } @@ -1682,13 +1775,13 @@ extension UICollectionViewController: ControllerUINavigationDelegate { } private func restoreFirstControllerNavigationItemIfNeeded() { - guard collectionView.numberOfSections > 0, - collectionView.numberOfItems(inSection: 0) > 0 else { + guard controllerNavigationCollectionView.numberOfSections > 0, + controllerNavigationCollectionView.numberOfItems(inSection: 0) > 0 else { clearCollectionControllerNavigationHighlight() return } - highlightControllerNavigationItem(at: IndexPath(item: 0, section: 0)) + controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: 0, section: 0)) } private func performControllerNavigationSelectionMove(horizontalOffset: Int) { @@ -1704,23 +1797,23 @@ extension UICollectionViewController: ControllerUINavigationDelegate { } private func moveControllerNavigationSelection(horizontalOffset: Int) { - let itemCount = collectionView.numberOfItems(inSection: 0) + let itemCount = controllerNavigationCollectionView.numberOfItems(inSection: 0) guard itemCount > 0 else { clearCollectionControllerNavigationHighlight() return } guard let currentIndexPath = currentControllerNavigationIndexPath() else { - highlightControllerNavigationItem(at: IndexPath(item: horizontalOffset >= 0 ? 0 : itemCount - 1, section: 0)) + controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: horizontalOffset >= 0 ? 0 : itemCount - 1, section: 0)) return } let nextItem = (currentIndexPath.item + horizontalOffset + itemCount) % itemCount - highlightControllerNavigationItem(at: IndexPath(item: nextItem, section: currentIndexPath.section)) + controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: nextItem, section: currentIndexPath.section)) } private func moveControllerNavigationSelection(verticalOffset: Int) { - let itemCount = collectionView.numberOfItems(inSection: 0) + let itemCount = controllerNavigationCollectionView.numberOfItems(inSection: 0) guard itemCount > 0 else { clearCollectionControllerNavigationHighlight() return @@ -1728,7 +1821,7 @@ extension UICollectionViewController: ControllerUINavigationDelegate { guard let currentIndexPath = currentControllerNavigationIndexPath(), let currentAttributes = layoutAttributesForItem(at: currentIndexPath) else { - highlightControllerNavigationItem(at: IndexPath(item: verticalOffset >= 0 ? 0 : itemCount - 1, section: 0)) + controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: verticalOffset >= 0 ? 0 : itemCount - 1, section: 0)) return } @@ -1767,65 +1860,77 @@ extension UICollectionViewController: ControllerUINavigationDelegate { }?.indexPath if let nextIndexPath { - highlightControllerNavigationItem(at: nextIndexPath) + controllerNavigationHighlightItemForControllerNavigator(at: nextIndexPath) } } private func currentControllerNavigationIndexPath() -> IndexPath? { if let selectedIndexPath = controllerNavigationSelectedIndexPath, - selectedIndexPath.section < collectionView.numberOfSections, - selectedIndexPath.item < collectionView.numberOfItems(inSection: selectedIndexPath.section) { + selectedIndexPath.section < controllerNavigationCollectionView.numberOfSections, + selectedIndexPath.item < controllerNavigationCollectionView.numberOfItems(inSection: selectedIndexPath.section) { return selectedIndexPath } if let highlightedView = ControllerNavigator.controllerNavigationHighlightedView, let highlightedCell = collectionViewCell(containing: highlightedView), - let indexPath = collectionView.indexPath(for: highlightedCell) { + let indexPath = controllerNavigationCollectionView.indexPath(for: highlightedCell) { return indexPath } return nil } - private func highlightControllerNavigationItem(at indexPath: IndexPath) { + func controllerNavigationCurrentIndexPathForControllerNavigator() -> IndexPath? { + currentControllerNavigationIndexPath() + } + + func controllerNavigationHighlightItemForControllerNavigator(at indexPath: IndexPath) { PublicUtils.runOnMain { [weak self] in guard let self else { return } - guard indexPath.section < self.collectionView.numberOfSections, + let collectionView = self.controllerNavigationCollectionView + guard indexPath.section < collectionView.numberOfSections, indexPath.item >= 0, - indexPath.item < self.collectionView.numberOfItems(inSection: indexPath.section) else { + indexPath.item < collectionView.numberOfItems(inSection: indexPath.section) else { self.clearCollectionControllerNavigationHighlight() return } - self.collectionView.layoutIfNeeded() + collectionView.layoutIfNeeded() let previousIndexPath = self.controllerNavigationSelectedIndexPath + self.controllerNavigationHighlightGeneration += 1 + let highlightGeneration = self.controllerNavigationHighlightGeneration self.controllerNavigationSelectedIndexPath = indexPath if let previousIndexPath, previousIndexPath != indexPath, - let previousCell = self.collectionView.cellForItem(at: previousIndexPath) { + let previousCell = collectionView.cellForItem(at: previousIndexPath) { self.applyControllerNavigationHighlight(to: previousCell, highlighted: false) } - self.collectionView.scrollToItem(at: indexPath, at: [.centeredHorizontally, .centeredVertically], animated: true) + collectionView.scrollToItem(at: indexPath, at: [.centeredHorizontally, .centeredVertically], animated: true) + self.applyControllerNavigationHighlightWhenCellIsReady(at: indexPath, generation: highlightGeneration, remainingAttempts: 8) + } + } - if let cell = self.collectionView.cellForItem(at: indexPath) { - self.applyControllerNavigationHighlight(to: cell, highlighted: true) - ControllerNavigator.controllerNavigationHighlightedView = self.controllerNavigationHighlightTargetView(for: cell) - } + private func applyControllerNavigationHighlightWhenCellIsReady(at indexPath: IndexPath, generation: Int, remainingAttempts: Int) { + guard controllerNavigationSelectedIndexPath == indexPath, + controllerNavigationHighlightGeneration == generation else { return } - DispatchQueue.main.async { [weak self] in - guard let self, - self.controllerNavigationSelectedIndexPath == indexPath, - let cell = self.collectionView.cellForItem(at: indexPath) else { - return - } + let collectionView = controllerNavigationCollectionView + collectionView.layoutIfNeeded() - self.applyControllerNavigationHighlight(to: cell, highlighted: true) - ControllerNavigator.controllerNavigationHighlightedView = self.controllerNavigationHighlightTargetView(for: cell) - } + if let cell = collectionView.cellForItem(at: indexPath) { + applyControllerNavigationHighlight(to: cell, highlighted: true) + ControllerNavigator.controllerNavigationHighlightedView = controllerNavigationHighlightTargetView(for: cell) + return + } + + guard remainingAttempts > 0 else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.applyControllerNavigationHighlightWhenCellIsReady(at: indexPath, generation: generation, remainingAttempts: remainingAttempts - 1) } } @@ -1834,21 +1939,28 @@ extension UICollectionViewController: ControllerUINavigationDelegate { guard let self else { return } if let selectedIndexPath = self.controllerNavigationSelectedIndexPath, - let cell = self.collectionView.cellForItem(at: selectedIndexPath) { + let cell = self.controllerNavigationCollectionView.cellForItem(at: selectedIndexPath) { self.applyControllerNavigationHighlight(to: cell, highlighted: false) } + self.controllerNavigationHighlightGeneration += 1 self.controllerNavigationSelectedIndexPath = nil ControllerNavigator.controllerNavigationHighlightedView = nil } } - fileprivate func clearCollectionControllerNavigationHighlightForControllerNavigator() { + func clearCollectionControllerNavigationHighlightForControllerNavigator() { clearCollectionControllerNavigationHighlight() } + func invalidateCollectionControllerNavigationHighlightForControllerNavigator() { + controllerNavigationHighlightGeneration += 1 + controllerNavigationSelectedIndexPath = nil + ControllerNavigator.controllerNavigationHighlightedView = nil + } + private func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { - collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) + controllerNavigationCollectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) } private func collectionViewCell(containing view: UIView) -> UICollectionViewCell? { diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index 18213fb4..c363f854 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -866,7 +866,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Close" + "value" : "Close Window" } }, "zh-Hans" : { @@ -2519,25 +2519,25 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nvidia Rec" + "value" : "NVIDIA Recording" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "N卡录屏" + "value" : "NVIDIA 录屏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "N卡錄影" + "value" : "NVIDIA 螢幕錄影" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "N卡錄影" + "value" : "NVIDIA 螢幕錄影" } } } @@ -2548,25 +2548,25 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nvidia Shot" + "value" : "NVIDIA Screenshot" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "N卡截图" + "value" : "NVIDIA 截图" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "N卡截圖" + "value" : "NVIDIA 螢幕截圖" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "N卡截圖" + "value" : "NVIDIA 螢幕截圖" } } } @@ -2832,6 +2832,35 @@ } } }, + "=projectDisplay" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Project Display" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "投影显示" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "投影顯示" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "投影顯示" + } + } + } + }, "=prone" : { "extractionState" : "manual", "localizations" : { @@ -3470,6 +3499,64 @@ } } }, + "=steamOverlay" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam Overlay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam 覆盖层" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam 覆蓋介面" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam 覆蓋介面" + } + } + } + }, + "=switchDesktop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Switch to Desktop" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换到桌面" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "切換到桌面" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "切換到桌面" + } + } + } + }, "=switchWindow" : { "extractionState" : "manual", "localizations" : { @@ -3586,6 +3673,151 @@ } } }, + "=toolboxDisconnectQuit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnect & Quit" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "断开并退出" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "斷線並退出" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "斷線並退出" + } + } + } + }, + "=toolboxSoftKeyboard" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soft Keyboard" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "软键盘" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "軟體鍵盤" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "軟體鍵盤" + } + } + } + }, + "=toolboxStatsOverlay" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stats Overlay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计覆盖层" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "統計覆蓋層" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "統計覆蓋層" + } + } + } + }, + "=toolboxSwitchProfile" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Switch Profile" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换配置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "切換配置" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "切換配置" + } + } + } + }, + "=toolboxWidgetLayout" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit Layout" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑布局" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯佈局" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "編輯佈局" + } + } + } + }, "=ult" : { "extractionState" : "manual", "localizations" : { @@ -3876,6 +4108,35 @@ } } }, + "=xboxGameBar" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xbox Game Bar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xbox 游戏栏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xbox 遊戲列" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xbox 遊戲列" + } + } + } + }, "=zoomIn" : { "extractionState" : "manual", "localizations" : { @@ -4256,6 +4517,35 @@ } } }, + "addEntry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Entry" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增条目" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增條目" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增條目" + } + } + } + }, "Adjust the pressure curve by adding or dragging the purple squares." : { "extractionState" : "manual", "localizations" : { @@ -6716,6 +7006,35 @@ } } }, + "doublePressPinExecute" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double Press Pin Execute" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "双击执行不退出" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙擊執行不退出" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "雙擊執行不退出" + } + } + } + }, "doublePressToAddFavorite" : { "extractionState" : "manual", "localizations" : { @@ -7742,6 +8061,35 @@ } } }, + "execute" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Execute" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "执行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "執行" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "執行" + } + } + } + }, "exit" : { "extractionState" : "manual", "localizations" : { @@ -10193,35 +10541,6 @@ } } }, - "leftButton" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Left Button" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "鼠标左键" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "滑鼠左鍵" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "滑鼠左鍵" - } - } - } - }, "Layout of the Default profile can not be changed" : { "extractionState" : "manual", "localizations" : { @@ -10504,6 +10823,35 @@ } } }, + "leftButton" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Left Button" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "鼠标左键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠左鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠左鍵" + } + } + } + }, "legacyFramePacingTip" : { "extractionState" : "manual", "localizations" : { @@ -11410,6 +11758,29 @@ } } }, + "Move the stick you want to use for mouse control. The other stick will be used for vertical and horizontal scrolling" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移动用于控制鼠标指针的摇杆。另一侧的摇杆将用于控制垂直、水平滚动。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "移動用於控制滑鼠指標的搖桿。另一側的搖桿將用於控制垂直、水平滾動。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "移動用於控制滑鼠指標的搖桿。另一側的搖桿將用於控制垂直、水平滾動。" + } + } + } + }, "moveCursor" : { "extractionState" : "manual", "localizations" : { @@ -11439,29 +11810,6 @@ } } }, - "Move the stick you want to use for mouse control. The other stick will be used for vertical and horizontal scrolling" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "移动用于控制鼠标指针的摇杆。另一侧的摇杆将用于控制垂直、水平滚动。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "移動用於控制滑鼠指標的搖桿。另一側的搖桿將用於控制垂直、水平滾動。" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "移動用於控制滑鼠指標的搖桿。另一側的搖桿將用於控制垂直、水平滾動。" - } - } - } - }, "name" : { "extractionState" : "manual", "localizations" : { @@ -14679,35 +15027,6 @@ } } }, - "rightButton" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Right Button" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "鼠标右键" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "滑鼠右鍵" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "滑鼠右鍵" - } - } - } - }, "Right Button" : { "extractionState" : "manual", "localizations" : { @@ -14777,6 +15096,35 @@ } } }, + "rightButton" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Right Button" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "鼠标右键" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠右鍵" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "滑鼠右鍵" + } + } + } + }, "Roll factor: %.2f" : { "extractionState" : "manual", "localizations" : { @@ -17618,35 +17966,6 @@ } } }, - "verticalHorizontalScroll" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Scroll" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "横纵滚动" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "橫縱捲動" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "橫縱捲動" - } - } - } - }, "Velocity-based right stick pad" : { "extractionState" : "manual", "localizations" : { @@ -17730,6 +18049,35 @@ }, "Version %@" : { + }, + "verticalHorizontalScroll" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scroll" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "横纵滚动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "橫縱捲動" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "橫縱捲動" + } + } + } }, "Video" : { "extractionState" : "manual", @@ -18514,4 +18862,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/VoidLink/ViewControllers/HostCollectionViewController.swift b/VoidLink/ViewControllers/HostCollectionViewController.swift index 99129408..6a31aa35 100644 --- a/VoidLink/ViewControllers/HostCollectionViewController.swift +++ b/VoidLink/ViewControllers/HostCollectionViewController.swift @@ -208,7 +208,9 @@ class HostCollectionViewController: UICollectionViewController, UICollectionView if let host = items[indexPath.item] as? TemporaryHost { cell.configure(with: host) } - applyControllerNavigationHighlight(to: cell, highlighted: indexPath == controllerNavigationSelectedIndexPath) + if #available(iOS 13.0, *) { + applyControllerNavigationHighlight(to: cell, highlighted: indexPath == controllerNavigationSelectedIndexPath) + } return cell } diff --git a/VoidLink/ViewControllers/StreamFrameViewController.h b/VoidLink/ViewControllers/StreamFrameViewController.h index 9c6c6e5c..1e54bc9e 100644 --- a/VoidLink/ViewControllers/StreamFrameViewController.h +++ b/VoidLink/ViewControllers/StreamFrameViewController.h @@ -62,5 +62,6 @@ - (void)setMagnifierViewportInteractionEnabled:(BOOL)enabled; - (void)resetMagnifierStreamViewWithAnimated:(BOOL)animated; - (void)returnToMainFrame; +- (void)bringUpToolboxMenu; @end diff --git a/VoidLink/ViewControllers/ToolboxViewController.swift b/VoidLink/ViewControllers/ToolboxViewController.swift index 9a3ab1a7..9baa67a8 100644 --- a/VoidLink/ViewControllers/ToolboxViewController.swift +++ b/VoidLink/ViewControllers/ToolboxViewController.swift @@ -1,5 +1,5 @@ // -// KeyManagerViewController.swift +// ToolboxViewController.swift // VoidLink // // Created by True砖家 on 2024/7/23. @@ -17,277 +17,527 @@ import UIKit @objc optional func disconnectAndQuitApp() } +@objc public class ToolboxViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout, UIGestureRecognizerDelegate, WidgetPickerViewControllerDelegate { + + private enum ToolboxEntry { + case special(id: String, title: String) + case command(offset: Int, command: RemoteCommand) + + var isSpecial: Bool { + if case .special = self { return true } + return false + } + + var orderIdentifier: String { + switch self { + case .special(let id, _): + return "special:\(id)" + case .command(_, let command): + return "command:\(command.identifier)" + } + } + } + + private enum Metrics { + static let contentCornerRadius: CGFloat = 22 + static let cardCornerRadius: CGFloat = 16 + static let buttonCornerRadius: CGFloat = 13 + } + + private static let entryOrderDefaultsKey = "ToolboxViewController.entryOrder" + private static let controllerNavigationHighlightDefaultsKey = "ToolboxViewController.controllerNavigationHighlightedEntry" -@objc public class ToolboxViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, UIGestureRecognizerDelegate, WidgetPickerViewControllerDelegate { - @objc weak var specialEntryDelegate: ToolboxSpecialEntryDelegate? - public let tableView = UITableView() + + // Kept for Objective-C/source compatibility with older callers. The UI is now collectionView. + public let tableView = UITableView(frame: .zero) + public private(set) var collectionView: UICollectionView! + private let addButton = UIButton(type: .system) private let deleteButton = UIButton(type: .system) private let editButton = UIButton(type: .system) - private let exitButton = UIButton(type: .system) private let pinButton = UIButton(type: .system) - private let viewBackgroundColor = UIColor(white: 0.2, alpha: 0.8); - private let highlightColor = UIColor(white: 0.55, alpha: 0.8); private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let toolbarView = UIView() + private let toolbarStackView = UIStackView() private var contentView = UIView() + private var pinButtonWidthConstraint: NSLayoutConstraint? + private var layoutConstraints: [NSLayoutConstraint] = [] + private var toolbarButtonConstraints: [NSLayoutConstraint] = [] + private var pendingTransitionSize: CGSize? - @objc public var specialEntries : NSMutableArray = ["widgetSwitchTool", "widgetLayoutTool", "bringUpSoftKeyboard", "enterPip", "toggleStatsOverlay", "disconnectAndQuitApp"] - private let specialEntryAliasDic : [String:String] = [ - "widgetSwitchTool":LocalizationHelper.localizedString(forKey: "[ Switch game profile ]"), - "widgetLayoutTool":LocalizationHelper.localizedString(forKey: "[ Edit on-screen widget layout ]"), - "bringUpSoftKeyboard":LocalizationHelper.localizedString(forKey: "[ Bring up soft keyboard ]"), - "enterPip":LocalizationHelper.localizedString(forKey: "[ Enter picture-in-picture mode ]"), - "toggleStatsOverlay":LocalizationHelper.localizedString(forKey: "[ Toggle stats overlay ]"), - "disconnectAndQuitApp":LocalizationHelper.localizedString(forKey: "[ Disconnect & quit app ]") + @objc public var specialEntries: NSMutableArray = ["widgetSwitchTool", "widgetLayoutTool", "bringUpSoftKeyboard", "enterPip", "toggleStatsOverlay", "disconnectAndQuitApp"] + private let specialEntryAliasDic: [String: String] = [ + "widgetSwitchTool": "=toolboxSwitchProfile", + "widgetLayoutTool": "=toolboxWidgetLayout", + "bringUpSoftKeyboard": "=toolboxSoftKeyboard", + "enterPip": "=enterPiP", + "toggleStatsOverlay": "=toolboxStatsOverlay", + "disconnectAndQuitApp": "=toolboxDisconnectQuit" ] - - private var viewPinned: Bool = false - private var isEditingMode: Bool = false { + + private var entries: [ToolboxEntry] = [] + private var viewPinned = false + private var selectedCommandOffsets = Set() + private var lastSelectedCommandOffset: Int? + private var controllerNavigationReorderActive = false + private var controllerNavigationReorderUpdateInFlight = false + private var pendingControllerNavigationReorderMove: (horizontal: Int, vertical: Int)? + private var controllerNavigationReorderSnapshotView: UIView? + private var controllerNavigationReorderHiddenIndexPath: IndexPath? + private var controllerNavigationReorderPressBeganAt: CFTimeInterval? + private var controllerNavigationPersistOrderWhenReorderSettles = false + private var controllerNavigationDeleteDoublePressWindowOpen = false + private var controllerNavigationDeleteDoublePressToken = 0 + private var controllerNavigationExecuteDismissWindowOpen = false + private var controllerNavigationExecuteDismissToken = 0 + private var controllerNavigationExecuteSuppressNextRelease = false + private var isEditingMode = false { didSet { + if !isEditingMode { + selectedCommandOffsets.removeAll() + } updateEditingMode() + collectionView?.reloadData() } } - - override public func viewDidLoad() { - + + public override func viewDidLoad() { super.viewDidLoad() - + setupViews() - updateEditingMode() - - tableView.dataSource = self - tableView.delegate = self - - // Register the cell class - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") - + setupConstraints() + reloadTableView() + let tap = UITapGestureRecognizer(target: self, action: #selector(dismissSelf)) tap.delegate = self view.addGestureRecognizer(tap) - - //pass self to the CommandManager + + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleCollectionLongPress(_:))) + longPress.minimumPressDuration = 0.25 + collectionView.addGestureRecognizer(longPress) + CommandManager.shared.viewController = self } - - - override public func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - //setupConstraints() - reloadTableView() - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - //setupConstraints() + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if #available(iOS 13.0, *), ControllerUtil.primaryGCController != nil { + ControllerNavigator.setUINavigationDelegate(self) + ControllerNavigator.restoreUINavigationHighlight() + } + } + + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if #available(iOS 13.0, *) { + if controllerNavigationReorderActive { + controllerNavigationReorderActive = false + pendingControllerNavigationReorderMove = nil + } + controllerNavigationReorderUpdateInFlight = false + controllerNavigationPersistOrderWhenReorderSettles = false + closeControllerNavigationDeleteDoublePressWindow() + closeControllerNavigationExecuteDismissWindow() + controllerNavigationExecuteSuppressNextRelease = false + controllerNavigationReorderSnapshotView?.removeFromSuperview() + controllerNavigationReorderSnapshotView = nil + controllerNavigationReorderHiddenIndexPath = nil + updateVisibleReorderHiddenCells() + persistControllerNavigationHighlight() + ControllerNavigator.restorePreviousUINavigationDelegate(ifCurrentDelegateIs: self) + } + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + collectionView.collectionViewLayout.invalidateLayout() } public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) - - DispatchQueue.main.asyncAfter(deadline: .now()+0.2) { - self.pinButton.isHidden = !PublicUtils.viewIsLandscape(self.view) && PublicUtils.isIPhone - } - - view.setNeedsUpdateConstraints() + + pendingTransitionSize = size + setupConstraints() coordinator.animate(alongsideTransition: { _ in + self.updatePinVisibility() + self.collectionView.collectionViewLayout.invalidateLayout() + self.view.layoutIfNeeded() + }, completion: { _ in + self.pendingTransitionSize = nil + self.setupConstraints() + self.updatePinVisibility() + self.collectionView.collectionViewLayout.invalidateLayout() self.view.layoutIfNeeded() }) } private func setupViews() { - contentView = UIView(frame: self.view.frame) - - // Set corner radius - contentView.layer.cornerRadius = 20 // Adjust the corner radius + view.backgroundColor = .clear + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.layer.cornerRadius = Metrics.contentCornerRadius contentView.layer.masksToBounds = true - - // Set up the title label - titleLabel.text = LocalizationHelper.localizedString(forKey: "Toolbox") - titleLabel.font = UIFont.boldSystemFont(ofSize: 20) // Adjust font size as needed - titleLabel.textColor = UIColor.white // Adjust color as needed + view.addSubview(contentView) + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.text = "Toolbox".localized + titleLabel.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 18 : 24, weight: .bold) titleLabel.textAlignment = .center - if !PublicUtils.isIPhone {contentView.addSubview(titleLabel)} - - contentView.backgroundColor = viewBackgroundColor - tableView.backgroundColor = .clear - tableView.rowHeight = isIPhone() ? 49.9 : 60 - tableView.separatorColor = .white.withAlphaComponent(0.33) - - if PublicUtils.liquidGlassEnabled { - tableView.separatorColor = .white.withAlphaComponent(PublicUtils.isIPhone ? 0.185 : 0.13) - } - else{ - tableView.separatorColor = .white.withAlphaComponent(0.3) - } - - tableView.separatorInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) - // Configure buttons - addButton.setTitle(LocalizationHelper.localizedString(forKey: "Add"), for: .normal) - deleteButton.setTitle(LocalizationHelper.localizedString(forKey: "Delete"), for: .normal) - editButton.setTitle(LocalizationHelper.localizedString(forKey: "Edit"), for: .normal) - exitButton.setTitle(LocalizationHelper.localizedString(forKey: "Exit"), for: .normal) - pinButton.setTitle("📌", for: .normal) - addButton.titleLabel?.font = UIFont.systemFont(ofSize: 20) // Adjust the size as needed - deleteButton.titleLabel?.font = UIFont.systemFont(ofSize: 20) - editButton.titleLabel?.font = UIFont.systemFont(ofSize: 20) - exitButton.titleLabel?.font = UIFont.systemFont(ofSize: 20) - pinButton.titleLabel?.font = UIFont.systemFont(ofSize: 20) - // Add subviews - contentView.addSubview(tableView) - contentView.addSubview(addButton) - contentView.addSubview(deleteButton) - contentView.addSubview(editButton) - // contentView.addSubview(exitButton) - contentView.addSubview(pinButton) - pinButton.isHidden = !PublicUtils.isLandscape() - - self.view.addSubview(contentView) - - // Setup button targets - addButton.addTarget(self, action: #selector(addButtonTapped), for: .touchUpInside) - deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + subtitleLabel.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 11 : 13, weight: .medium) + subtitleLabel.textAlignment = .center + subtitleLabel.numberOfLines = 1 + subtitleLabel.adjustsFontSizeToFitWidth = true + subtitleLabel.minimumScaleFactor = 0.75 + + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .vertical + layout.minimumInteritemSpacing = gridSpacing() + layout.minimumLineSpacing = gridSpacing() + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.backgroundColor = collectionSurfaceColor() + collectionView.layer.cornerRadius = PublicUtils.isIPhone ? 14 : 18 + collectionView.layer.masksToBounds = true + collectionView.alwaysBounceVertical = true + collectionView.dataSource = self + collectionView.delegate = self + collectionView.allowsSelection = true + collectionView.allowsMultipleSelection = false + collectionView.contentInsetAdjustmentBehavior = .never + collectionView.register(ToolboxCardCell.self, forCellWithReuseIdentifier: ToolboxCardCell.reuseIdentifier) + + toolbarView.translatesAutoresizingMaskIntoConstraints = false + toolbarView.layer.cornerRadius = Metrics.buttonCornerRadius + toolbarView.layer.masksToBounds = true + + toolbarStackView.translatesAutoresizingMaskIntoConstraints = false + toolbarStackView.axis = .horizontal + toolbarStackView.alignment = .fill + toolbarStackView.distribution = .fill + toolbarStackView.spacing = PublicUtils.isIPhone ? 6 : 8 + + configureToolbarButton(pinButton, title: "", symbolName: "pin") + configureToolbarButton(deleteButton, title: "Delete".localized, symbolName: "minus.circle") + configureToolbarButton(addButton, title: "Add".localized, symbolName: "plus.circle") + configureToolbarButton(editButton, title: "Edit".localized, symbolName: "pencil") + editButton.addTarget(self, action: #selector(editButtonTapped), for: .touchUpInside) - exitButton.addTarget(self, action: #selector(exitButtonTapped), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) + addButton.addTarget(self, action: #selector(addButtonTapped), for: .touchUpInside) pinButton.addTarget(self, action: #selector(pinButtonTapped), for: .touchUpInside) - pinButton.contentEdgeInsets = UIEdgeInsets(top: 7, left: 20, bottom: 7, right: 20) - } - - public override func updateViewConstraints() { - self.setupConstraints() - super.updateViewConstraints() + + contentView.addSubview(collectionView) + contentView.addSubview(toolbarView) + toolbarView.addSubview(toolbarStackView) + [pinButton, deleteButton, addButton, editButton].forEach(toolbarStackView.addArrangedSubview) + + updateTheme() + updatePinVisibility() + updateEditingMode() } - private func isIPhone()->Bool { - return UIDevice.current.userInterfaceIdiom == .phone + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateTheme() + collectionView.visibleCells.forEach { cell in + (cell as? ToolboxCardCell)?.applyTheme() + } + } + + private func updateTheme() { + let darkMode = traitCollection.userInterfaceStyle == .dark + contentView.backgroundColor = darkMode + ? UIColor(white: 0.09, alpha: 0.88) + : UIColor(white: 0.94, alpha: 0.8) + toolbarView.backgroundColor = darkMode + ? UIColor(white: 1.0, alpha: 0.08) + : UIColor(white: 1.0, alpha: 0.41) + titleLabel.textColor = darkMode ? .white : UIColor(white: 0.08, alpha: 1) + subtitleLabel.textColor = darkMode ? UIColor(white: 1, alpha: 0.54) : UIColor(white: 0, alpha: 0.46) + collectionView?.backgroundColor = collectionSurfaceColor() + updateToolbarColors() + } + + private func updateToolbarColors() { + let darkMode = traitCollection.userInterfaceStyle == .dark + let normalTint = darkMode ? UIColor(white: 1, alpha: 0.86) : UIColor(white: 0.10, alpha: 0.86) + let disabledTint = darkMode ? UIColor(white: 1, alpha: 0.25) : UIColor(white: 0, alpha: 0.24) + + [addButton, deleteButton, editButton, pinButton].forEach { button in + button.tintColor = button.isEnabled ? normalTint : disabledTint + button.setTitleColor(button.isEnabled ? normalTint : disabledTint, for: .normal) + button.backgroundColor = .clear + } + + if viewPinned { + pinButton.tintColor = UIColor.systemOrange + pinButton.backgroundColor = UIColor.systemOrange.withAlphaComponent(darkMode ? 0.24 : 0.18) + } + } + + @objc public func setupConstraints() { + NSLayoutConstraint.deactivate(layoutConstraints) + layoutConstraints.removeAll() + + let topInset: CGFloat = PublicUtils.isIPhone ? 10 : 18 + let sideInset: CGFloat = PublicUtils.isIPhone ? 10 : 18 + let toolbarVerticalInset: CGFloat = PublicUtils.isIPhone ? 5 : 11 + let toolbarHeight: CGFloat = PublicUtils.isIPhone ? 36 : 54 + + layoutConstraints = [ + contentView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: contentWidthMultiplier()), + contentView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: PublicUtils.isIPhone ? 0.93 : 0.93), + contentView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + // contentView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + + contentView.topAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + contentView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: PublicUtils.isIPhone ? 3 : -5), + + toolbarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: sideInset), + toolbarView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -sideInset), + toolbarView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -toolbarVerticalInset), + toolbarView.heightAnchor.constraint(equalToConstant: toolbarHeight), + + toolbarStackView.leadingAnchor.constraint(equalTo: toolbarView.leadingAnchor, constant: PublicUtils.isIPhone ? 6 : 8), + toolbarStackView.trailingAnchor.constraint(equalTo: toolbarView.trailingAnchor, constant: PublicUtils.isIPhone ? -6 : -8), + toolbarStackView.topAnchor.constraint(equalTo: toolbarView.topAnchor, constant: PublicUtils.isIPhone ? 3 : 8), + toolbarStackView.bottomAnchor.constraint(equalTo: toolbarView.bottomAnchor, constant: PublicUtils.isIPhone ? -3 : -8), + + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: sideInset), + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -sideInset), + collectionView.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: topInset), + collectionView.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: -toolbarVerticalInset), + ] + NSLayoutConstraint.activate(layoutConstraints) + + setupToolbarConstraints() + } + + private func setupToolbarConstraints() { + NSLayoutConstraint.deactivate(toolbarButtonConstraints) + toolbarButtonConstraints.removeAll() + + [editButton, deleteButton, pinButton, addButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + + let pinWidth: CGFloat = PublicUtils.isIPhone ? 44 : 52 + + pinButtonWidthConstraint = pinButton.widthAnchor.constraint(equalToConstant: pinWidth) + + toolbarButtonConstraints = [ + deleteButton.widthAnchor.constraint(equalTo: editButton.widthAnchor), + pinButtonWidthConstraint!, + addButton.widthAnchor.constraint(equalTo: editButton.widthAnchor), + ] + NSLayoutConstraint.activate(toolbarButtonConstraints) } private func contentWidthMultiplier() -> CGFloat { - /* - if isIPhone() && PublicUtils.viewIsLandscape(self.view) { - return 0.85 - }*/ - - return isIPhone() - ? (PublicUtils.viewIsLandscape(self.view) ? 0.6 : 0.85) - : (PublicUtils.viewIsLandscape(self.view) ? 0.6 : 0.85) + if PublicUtils.isIPhone { + return isLayoutLandscape() ? 0.62 : 0.88 + } + return isLayoutLandscape() ? 0.595 : 0.86 } - - - @objc public func setupConstraints() { - - NSLayoutConstraint.deactivate(view.constraints) - contentView.translatesAutoresizingMaskIntoConstraints = false - - tableView.translatesAutoresizingMaskIntoConstraints = false - addButton.translatesAutoresizingMaskIntoConstraints = false - deleteButton.translatesAutoresizingMaskIntoConstraints = false - editButton.translatesAutoresizingMaskIntoConstraints = false - // exitButton.translatesAutoresizingMaskIntoConstraints = false - pinButton.translatesAutoresizingMaskIntoConstraints = false - titleLabel.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - - // Set the width and height of the view - contentView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: contentWidthMultiplier()), - contentView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.93), - // Set the width and height of the view - //view.leadingAnchor.constraint(equalTo: view.superview!.leadingAnchor, constant: 60), - //view.trailingAnchor.constraint(equalTo: view.superview!.trailingAnchor, constant: -60), + private func isLayoutLandscape() -> Bool { + if let pendingTransitionSize { + return pendingTransitionSize.width > pendingTransitionSize.height + } + return PublicUtils.viewIsLandscape(view) + } - // Center the view horizontally and vertically - contentView.centerXAnchor.constraint(equalTo: view.centerXAnchor), - contentView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - - // TableView constraints - tableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10), - tableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10), - tableView.topAnchor.constraint(equalTo: contentView.topAnchor, constant:PublicUtils.isIPhone ? 6 : 50), - tableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -60), - - // ExitButton constraints - // exitButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 25), - // exitButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), - - // EditButton constraints - editButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 25), - editButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), - - // AddButton constraints - addButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -25), - addButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), - - // DeleteButton constraints - deleteButton.trailingAnchor.constraint(equalTo: addButton.leadingAnchor, constant: -25), - deleteButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), - - // DeleteButton constraints - pinButton.trailingAnchor.constraint(equalTo: deleteButton.leadingAnchor, constant: -25), - pinButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), - ]) + private func gridSpacing() -> CGFloat { + return PublicUtils.isIPhone ? 8 : 12 + } + + private func gridInsets() -> UIEdgeInsets { + let inset = PublicUtils.isIPhone ? 2.0 : 4.0 + return UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset) + } + + private func collectionSurfaceColor() -> UIColor { + if traitCollection.userInterfaceStyle == .dark { + return UIColor(white: 1.0, alpha: 0.035) + } + return UIColor(white: 1.0, alpha: 0.18) + } + + private func columnCount(for width: CGFloat) -> Int { + #if os(tvOS) + return width >= 780 ? 4 : 3 + #else + if PublicUtils.isIPhone { + return isLayoutLandscape() ? 3 : 2 + } + return width >= 760 ? 4 : 3 + #endif + } + + private func makeEntries() -> [ToolboxEntry] { + let specialEntries = specialEntries.compactMap { value -> ToolboxEntry? in + guard let id = value as? String else { return nil } + let title = specialEntryAliasDic[id] ?? id + return .special(id: id, title: title) + } + + let commandEntries = CommandManager.shared.getAllCommands().enumerated().map { + ToolboxEntry.command(offset: $0.offset, command: $0.element) + } + + let defaultEntries = specialEntries + commandEntries + guard let persistedOrder = UserDefaults.standard.stringArray(forKey: Self.entryOrderDefaultsKey), + !persistedOrder.isEmpty else { + return defaultEntries + } + + let entriesByID = Dictionary(uniqueKeysWithValues: defaultEntries.map { ($0.orderIdentifier, $0) }) + var usedIDs = Set() + var orderedEntries: [ToolboxEntry] = [] + + for identifier in persistedOrder { + guard let entry = entriesByID[identifier], + !usedIDs.contains(identifier) else { + continue + } + orderedEntries.append(entry) + usedIDs.insert(identifier) + } + + for entry in defaultEntries where !usedIDs.contains(entry.orderIdentifier) { + orderedEntries.append(entry) + } + + return orderedEntries + } + + private func entriesPreservingCurrentOrder(includeMissingEntries: Bool) -> [ToolboxEntry] { + let currentOrder = entries.map(\.orderIdentifier) + let specialEntries = specialEntries.compactMap { value -> ToolboxEntry? in + guard let id = value as? String else { return nil } + let title = specialEntryAliasDic[id] ?? id + return .special(id: id, title: title) + } + let commandEntries = CommandManager.shared.getAllCommands().enumerated().map { + ToolboxEntry.command(offset: $0.offset, command: $0.element) + } + let latestEntries = specialEntries + commandEntries + let latestEntriesByID = Dictionary(uniqueKeysWithValues: latestEntries.map { ($0.orderIdentifier, $0) }) + + var usedIDs = Set() + var reconciledEntries: [ToolboxEntry] = [] + + for identifier in currentOrder { + guard let entry = latestEntriesByID[identifier], + !usedIDs.contains(identifier) else { + continue + } + reconciledEntries.append(entry) + usedIDs.insert(identifier) + } + + if includeMissingEntries { + for entry in latestEntries where !usedIDs.contains(entry.orderIdentifier) { + reconciledEntries.append(entry) + } + } + + return reconciledEntries + } + + private func configureToolbarButton(_ button: UIButton, title: String, symbolName: String) { + button.layer.cornerRadius = Metrics.buttonCornerRadius + button.layer.masksToBounds = true + button.titleLabel?.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 11 : 15, weight: .semibold) + button.setTitle(title, for: .normal) + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: PublicUtils.isIPhone ? 8 : 11, bottom: 0, right: PublicUtils.isIPhone ? 8 : 12) + + let toolbarImage: UIImage? - // ViewTitle constrains - if !PublicUtils.isIPhone { - NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 13.5), - titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10), - titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10), - ]) + if #available(iOS 13.0, tvOS 13.0, *) { + let config = UIImage.SymbolConfiguration(pointSize: PublicUtils.isIPhone ? 9 : 11, weight: .semibold) + toolbarImage = UIImage(systemName: symbolName, withConfiguration: config) ?? UIImage(named: symbolName) + } else { + toolbarImage = UIImage(named: symbolName) + } + + if let image = toolbarImage { + button.setImage(image, for: .normal) + button.imageView?.contentMode = .scaleAspectFit + button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -2, bottom: 0, right: title.isEmpty ? -2 : 6) } } - private func updateEditingMode() { + collectionView?.allowsMultipleSelection = isEditingMode addButton.isEnabled = isEditingMode - deleteButton.isEnabled = isEditingMode - if(isEditingMode){ editButton.setTitle(LocalizationHelper.localizedString(forKey: "Done"), for: .normal) } - else{ editButton.setTitle(LocalizationHelper.localizedString(forKey: "Edit"), for: .normal) } + deleteButton.isEnabled = isEditingMode && !selectedCommandOffsets.isEmpty + editButton.setTitle(isEditingMode ? "Done".localized : "Edit".localized, for: .normal) + subtitleLabel.text = isEditingMode + ? "Select shortcuts to delete".localized + : "Tap a card to send a command".localized + updateToolbarColors() } - + + private func updatePinVisibility() { + let shouldHide = PublicUtils.isIPhone && !isLayoutLandscape() + pinButton.isHidden = shouldHide + pinButtonWidthConstraint?.constant = shouldHide ? 0 : (PublicUtils.isIPhone ? 44 : 52) + toolbarStackView.setCustomSpacing(shouldHide ? 0 : toolbarStackView.spacing, after: deleteButton) + toolbarStackView.setCustomSpacing(shouldHide ? 0 : toolbarStackView.spacing, after: pinButton) + } + @objc private func pinButtonTapped() { - viewPinned = !viewPinned - if viewPinned { - pinButton.backgroundColor = highlightColor - } - else{ - pinButton.backgroundColor = .clear - } + viewPinned.toggle() + updateToolbarColors() } @available(iOS 13.0, *) public func widgetPickerViewController(_ controller: WidgetPickerViewController, didCreateWidget payload: NSDictionary) { createEntry(cmdString: payload["cmdString"] as? String, alias: payload["buttonLabel"] as? String) } - - private func createEntry(cmdString: String?, alias: String?){ - + + private func createEntry(cmdString: String?, alias: String?) { let cmdString = cmdString ?? "" let alias = alias ?? cmdString - let previouslySelectedIndexPath = tableView.indexPathForSelectedRow //memorize selected indexpath - + let previousOffset = lastSelectedCommandOffset + let shouldPreserveEditingOrder = isEditingMode + let newCommand = RemoteCommand(cmdString: cmdString, alias: alias) - let addCommandSuceeded = CommandManager.shared.addCommand(newCommand) - //self.reloadTableView() // don't know why but this reload has to be called from the CommandManager, it doesn't work here. - - //if previouslySelectedIndexPath == nil { return } - if addCommandSuceeded { - let lastRow = self.tableView.numberOfRows(inSection: 0) - 1 //for now there's only 1 section for the tableview, just use setion 0 - let newEntryIndexPath = IndexPath(row: lastRow, section: 0) - self.tableView.selectRow(at: newEntryIndexPath, animated: true, scrollPosition: .middle) // shift the highlight to the newly added entry - } - else { - self.tableView.selectRow(at: previouslySelectedIndexPath, animated: true, scrollPosition: .middle) // keep the highlight on the previous entry if failed to add command + let addCommandSucceeded = CommandManager.shared.addCommand(newCommand) + + if addCommandSucceeded { + if shouldPreserveEditingOrder { + entries = entriesPreservingCurrentOrder(includeMissingEntries: true) + collectionView.reloadData() + } + let newOffset = max(CommandManager.shared.getAllCommands().count - 1, 0) + if isEditingMode { + scrollToCommand(offset: newOffset) + } else { + selectCommand(offset: newOffset, scrollPosition: .centeredVertically) + } + } else if let previousOffset { + if isEditingMode { + scrollToCommand(offset: previousOffset) + } else { + selectCommand(offset: previousOffset, scrollPosition: .centeredVertically) + } } } @objc private func addButtonTapped() { if #available(iOS 13.0, *) { let pickerViewController = WidgetPickerViewController() - pickerViewController.delegate = (self as WidgetPickerViewControllerDelegate) + pickerViewController.delegate = self pickerViewController.keyboardPickerMode = .shortcutPicker pickerViewController.shortcutPickerNeedAlias = true pickerViewController.tabIdentifiers = ["keyboard", "shortcuts"] @@ -295,157 +545,310 @@ import UIKit pickerViewController.presentOverFullScreen(from: self) return } - else{ - let alert = UIAlertController(title: LocalizationHelper.localizedString(forKey: "New Command"), message: LocalizationHelper.localizedString(forKey: "Enter a new command and alias"), preferredStyle: .alert) - alert.addTextField { $0.placeholder = LocalizationHelper.localizedString(forKey:"Command") } - alert.addTextField { $0.placeholder = LocalizationHelper.localizedString(forKey: "Alias (optional)") } - alert.textFields?[0].keyboardType = .asciiCapable - alert.textFields?[0].autocorrectionType = .no - alert.textFields?[0].spellCheckingType = .no - alert.textFields?[1].keyboardType = .default - alert.textFields?[1].autocorrectionType = .no - alert.textFields?[1].spellCheckingType = .no - - let submitAction = UIAlertAction(title: LocalizationHelper.localizedString(forKey: "Add"), style: .default) { [unowned alert] _ in - self.createEntry(cmdString: alert.textFields?[0].text ?? "", alias: alert.textFields?[1].text) - } - - let cancelAction = UIAlertAction(title: LocalizationHelper.localizedString(forKey:"Cancel"), style: .cancel) - alert.addAction(submitAction) - alert.addAction(cancelAction) - - if let selectedIndexPath = self.tableView.indexPathForSelectedRow { - if isSpecialEntrySelected() {return} - let selectedCommand = CommandManager.shared.getAllCommands()[selectedIndexPath.row-specialEntries.count] - alert.textFields?[0].text = selectedCommand.cmdString // load selected keyboard cmd string - //alert.textFields?[1].text = selectedCommand.alias // leave the alias input field empty - } - - self.present(alert, animated: true) + + let alert = UIAlertController(title: "New Command".localized, message: "Enter a new command and alias".localized, preferredStyle: .alert) + alert.addTextField { $0.placeholder = "Command".localized } + alert.addTextField { $0.placeholder = "Alias (optional)".localized } + alert.textFields?[0].keyboardType = .asciiCapable + alert.textFields?[0].autocorrectionType = .no + alert.textFields?[0].spellCheckingType = .no + alert.textFields?[1].keyboardType = .default + alert.textFields?[1].autocorrectionType = .no + alert.textFields?[1].spellCheckingType = .no + + let submitAction = UIAlertAction(title: "Add".localized, style: .default) { [unowned alert] _ in + self.createEntry(cmdString: alert.textFields?[0].text ?? "", alias: alert.textFields?[1].text) } + + alert.addAction(submitAction) + alert.addAction(UIAlertAction(title: "Cancel".localized, style: .cancel)) + present(alert, animated: true) } - - + @objc private func deleteButtonTapped() { - guard let selectedIndexPath = tableView.indexPathForSelectedRow else { - return + guard !selectedCommandOffsets.isEmpty else { return } + + let sortedOffsets = selectedCommandOffsets.sorted(by: >) + sortedOffsets.forEach { CommandManager.shared.deleteCommand(at: $0) } + + let nextOffset = max((sortedOffsets.min() ?? 0) - 1, 0) + selectedCommandOffsets.removeAll() + entries = entriesPreservingCurrentOrder(includeMissingEntries: false) + collectionView.reloadData() + updateEditingMode() + + if CommandManager.shared.getAllCommands().indices.contains(nextOffset) { + scrollToCommand(offset: nextOffset) } - - if isSpecialEntrySelected() {return} - - let previouslySelectedIndexPath = selectedIndexPath // memorize selected row before reloading tableview - - CommandManager.shared.deleteCommand(at: selectedIndexPath.row-specialEntries.count) - reloadTableView() - // target new entry after deletion - var targetRowAfterDel = previouslySelectedIndexPath.row - 1 - if targetRowAfterDel < 0 { targetRowAfterDel = 0 } - let targetIndexPathAfterDel = IndexPath(row: targetRowAfterDel, section: previouslySelectedIndexPath.section) - if targetRowAfterDel < tableView.numberOfRows(inSection: previouslySelectedIndexPath.section) { - tableView.selectRow(at: targetIndexPathAfterDel, animated: true, scrollPosition: .middle) // shift highlight to the target entry after deletion - } } - + @objc private func editButtonTapped() { + if isEditingMode { + persistEntryOrder() + } isEditingMode.toggle() - deleteButton.isEnabled = !isSpecialEntrySelected() - addButton.isEnabled = !isSpecialEntrySelected() } - - @objc private func exitButtonTapped() { - dismiss(animated: true, completion: nil) - } - + @objc public func reloadTableView() { - tableView.layoutIfNeeded() - tableView.reloadData() - - /* - let previouslySelectedIndexPath = tableView.indexPathForSelectedRow - if let indexPath = previouslySelectedIndexPath { - // Make sure the indexPath is still valid and scroll to the selected indexPath - if indexPath.row < tableView.numberOfRows(inSection: indexPath.section) { - tableView.selectRow(at: indexPath, animated: true, scrollPosition: .middle) // keep the entry of previous index selected. + entries = makeEntries() + collectionView?.reloadData() + updateEditingMode() + } + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return entries.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ToolboxCardCell.reuseIdentifier, for: indexPath) + guard let cardCell = cell as? ToolboxCardCell else { return cell } + + configure(cardCell, at: indexPath) + cardCell.isHidden = indexPath == controllerNavigationReorderHiddenIndexPath + return cardCell + } + + private func configure(_ cardCell: ToolboxCardCell, at indexPath: IndexPath) { + let entry = entries[indexPath.item] + switch entry { + case .special(let id, let title): + cardCell.configure( + title: title.localized, + subtitle: specialSubtitle(for: id), + symbolName: specialSymbolName(for: id), + kind: .special, + selectedForEditing: false, + editing: isEditingMode + ) + case .command(let offset, let command): + cardCell.configure( + title: command.alias.localized, + subtitle: command.cmdString, + symbolName: "keyboard", + kind: .command, + selectedForEditing: selectedCommandOffsets.contains(offset), + editing: isEditingMode + ) + } + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + activateEntry(at: indexPath) + } + + private func activateEntry(at indexPath: IndexPath, dismissAfterAction: Bool = true) { + guard entries.indices.contains(indexPath.item) else { return } + + switch entries[indexPath.item] { + case .special(let id, _): + collectionView.deselectItem(at: indexPath, animated: false) + if !isEditingMode { + flashCell(at: indexPath) + handleSpecialEntry(id: id) } - } */ - } - - // UITableViewDataSource - public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return specialEntries.count + CommandManager.shared.getAllCommands().count - } - - public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) - // Configure cell appearance - cell.backgroundColor = .clear // Set background color of the cell - cell.textLabel?.textColor = UIColor.white // Set font color of the text - cell.textLabel?.font = UIFont.systemFont(ofSize: 18.5) - - - // Set selected background view - let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = highlightColor // Color for the selected state - cell.selectedBackgroundView = selectedBackgroundView - - if indexPath.row < specialEntries.count { - cell.textLabel?.text = specialEntryAliasDic[specialEntries[indexPath.row] as! String] - } - else { - let command = CommandManager.shared.getAllCommands()[indexPath.row-specialEntries.count] - cell.textLabel?.text = LocalizationHelper.localizedString(forKey: command.alias) - } - return cell - } - - // UITableViewDelegate - public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - - if let cell = tableView.cellForRow(at: indexPath) { - // Set the cell background color to the flashing color - // Animate the flash effect - UIView.animate(withDuration: 0.1, animations: { - cell.selectedBackgroundView?.backgroundColor = .clear - }) { _ in - // Reset the cell background color after the animation - UIView.animate(withDuration: 0.1) { - cell.selectedBackgroundView?.backgroundColor = self.highlightColor + + case .command(let offset, let command): + lastSelectedCommandOffset = offset + if isEditingMode { + if selectedCommandOffsets.contains(offset) { + selectedCommandOffsets.remove(offset) + collectionView.deselectItem(at: indexPath, animated: true) + } else { + selectedCommandOffsets.insert(offset) + } + updateEditingMode() + if let cell = collectionView.cellForItem(at: indexPath) as? ToolboxCardCell { + configure(cell, at: indexPath) + } + } else { + flashCell(at: indexPath) + sendKeyboardCommand(command) + if dismissAfterAction && !viewPinned { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + self.dismiss(animated: false) + } } } } - - if !isEditingMode { - // Sending keyboard command - if indexPath.row < specialEntries.count{ - handleSpecialEntries(index: indexPath.row) - } - else { - let command = CommandManager.shared.getAllCommands()[indexPath.row-specialEntries.count] - sendKeyboardCommand(command) - if !viewPinned { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - self.dismiss(animated: false, completion: nil) - } - } // dimiss the view in sending mode & the view is not pinned - } - } - else { - let specialEntrySelected = indexPath.row < specialEntries.count - addButton.isEnabled = !specialEntrySelected - deleteButton.isEnabled = !specialEntrySelected + } + + public func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { + guard entries.indices.contains(indexPath.item) else { return } + guard case .command(let offset, _) = entries[indexPath.item] else { return } + + selectedCommandOffsets.remove(offset) + updateEditingMode() + if let cell = collectionView.cellForItem(at: indexPath) as? ToolboxCardCell { + configure(cell, at: indexPath) } } - - private func isSpecialEntrySelected() -> Bool { - return self.tableView.indexPathForSelectedRow?.row ?? specialEntries.count+1 < specialEntries.count + + public func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + guard entries.indices.contains(indexPath.item) else { return false } + return !isEditingMode || !entries[indexPath.item].isSpecial } - - private func handleSpecialEntries(index:Int){ + + public func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool { + return isEditingMode && entries.indices.contains(indexPath.item) + } + + public func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + guard isEditingMode, + entries.indices.contains(sourceIndexPath.item), + destinationIndexPath.item >= 0, + destinationIndexPath.item <= entries.count else { + return + } + + let movedEntry = entries.remove(at: sourceIndexPath.item) + let destination = min(destinationIndexPath.item, entries.count) + entries.insert(movedEntry, at: destination) + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + return gridInsets() + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return gridSpacing() + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return gridSpacing() + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let insets = gridInsets() + let columns = columnCount(for: collectionView.bounds.width) + let totalSpacing = CGFloat(columns - 1) * gridSpacing() + insets.left + insets.right + let width = floor((collectionView.bounds.width - totalSpacing) / CGFloat(columns)) + let height: CGFloat + + #if os(tvOS) + height = max(118, width * 0.62) + #else + if PublicUtils.isIPhone { + height = max(56, min(112, width * 0.59)) + } else { + height = max(108, min(140, width * 0.68)) + } + #endif + + return CGSize(width: width, height: height) + } + + @objc private func handleCollectionLongPress(_ gesture: UILongPressGestureRecognizer) { + guard isEditingMode else { return } + + let location = gesture.location(in: collectionView) + switch gesture.state { + case .began: + guard let indexPath = collectionView.indexPathForItem(at: location), + entries.indices.contains(indexPath.item) else { + return + } + collectionView.beginInteractiveMovementForItem(at: indexPath) + + case .changed: + collectionView.updateInteractiveMovementTargetPosition(location) + + case .ended: + collectionView.endInteractiveMovement() + + default: + collectionView.cancelInteractiveMovement() + } + } + + private func persistEntryOrder() { + let orderedSpecialIDs = entries.compactMap { entry -> String? in + if case .special(let id, _) = entry { return id } + return nil + } + specialEntries = NSMutableArray(array: orderedSpecialIDs) + + let orderedCommandIDs = entries.compactMap { entry -> String? in + if case .command(_, let command) = entry { return command.identifier } + return nil + } + CommandManager.shared.reorderCommands(withIdentifiers: orderedCommandIDs) + + UserDefaults.standard.set(entries.map(\.orderIdentifier), forKey: Self.entryOrderDefaultsKey) + entries = makeEntries() + } + + private func selectCommand(offset: Int, scrollPosition: UICollectionView.ScrollPosition) { + guard let item = entries.firstIndex(where: { + if case .command(let entryOffset, _) = $0 { return entryOffset == offset } + return false + }) else { return } + + let indexPath = IndexPath(item: item, section: 0) + lastSelectedCommandOffset = offset + collectionView.selectItem(at: indexPath, animated: true, scrollPosition: scrollPosition) + } + + private func scrollToCommand(offset: Int) { + guard let item = entries.firstIndex(where: { + if case .command(let entryOffset, _) = $0 { return entryOffset == offset } + return false + }) else { return } + + let indexPath = IndexPath(item: item, section: 0) + lastSelectedCommandOffset = offset + collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: true) + } + + private func flashCell(at indexPath: IndexPath) { + guard let cell = collectionView.cellForItem(at: indexPath) as? ToolboxCardCell else { return } + cell.flash() + } + + private func specialSubtitle(for id: String) -> String { + switch id { + case "widgetSwitchTool": + return "Profiles".localized + case "widgetLayoutTool": + return "Layout".localized + case "bringUpSoftKeyboard": + return "Keyboard".localized + case "enterPip": + return "PiP".localized + case "toggleStatsOverlay": + return "Stats".localized + case "disconnectAndQuitApp": + return "Session".localized + default: + return "" + } + } + + private func specialSymbolName(for id: String) -> String { + switch id { + case "widgetSwitchTool": + return "square.grid.2x2" + case "widgetLayoutTool": + return "rectangle.and.pencil.and.ellipsis" + case "bringUpSoftKeyboard": + return "keyboard" + case "enterPip": + return "pip.enter" + case "toggleStatsOverlay": + return "chart.bar" + case "disconnectAndQuitApp": + return "power" + default: + return "square" + } + } + + private func handleSpecialEntry(id: String) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - self.dismiss(animated: false, completion: nil) - switch self.specialEntries[index] as? String { + self.dismiss(animated: false) + + switch id { case "widgetLayoutTool": - self.specialEntryDelegate?.openWidgetLayoutTool!() + self.specialEntryDelegate?.openWidgetLayoutTool?() case "widgetSwitchTool": self.specialEntryDelegate?.openWidgetProfileTable?(pickProfile: false) case "bringUpSoftKeyboard": @@ -456,27 +859,704 @@ import UIKit self.specialEntryDelegate?.toggleStatsOverlay?() case "disconnectAndQuitApp": self.specialEntryDelegate?.disconnectAndQuitApp?() - default: break + default: + break } } } - - + private func sendKeyboardCommand(_ cmd: RemoteCommand) { - // print("Sending key-value") - let keyboardCmdStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: cmd.cmdString) - CommandManager.shared.sendAutoReleaseComboCommand(cmdStrings: keyboardCmdStrings!) + guard let keyboardCmdStrings = CommandManager.shared.extractAutoReleaseButtonStrings(from: cmd.cmdString) else { return } + CommandManager.shared.sendAutoReleaseComboCommand(cmdStrings: keyboardCmdStrings) } - - public func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldReceive touch: UITouch - ) -> Bool { + + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { return touch.view == view } - - @objc private func dismissSelf(){ - dismiss(animated: false, completion: nil) + + @objc private func dismissSelf() { + dismiss(animated: false) } } - + +@available(iOS 13.0, *) +extension ToolboxViewController: ControllerCollectionNavigationDelegate { + var controllerNavigationCollectionView: UICollectionView { + collectionView + } + + @objc func persistControllerNavigationHighlight() { + guard !controllerNavigationReorderActive else { return } + guard let indexPath = controllerNavigationCurrentIndexPathForControllerNavigator(), + entries.indices.contains(indexPath.item) else { + return + } + + UserDefaults.standard.set(entries[indexPath.item].orderIdentifier, forKey: Self.controllerNavigationHighlightDefaultsKey) + } + + @objc func restoreControllerNavigationHighlight() { + guard ControllerNavigator.enabled, ControllerUtil.primaryGCController != nil else { return } + + PublicUtils.runOnMain { [weak self] in + guard let self, !self.entries.isEmpty else { + self?.clearCollectionControllerNavigationHighlightForControllerNavigator() + return + } + + let savedIdentifier = UserDefaults.standard.string(forKey: Self.controllerNavigationHighlightDefaultsKey) + let item = savedIdentifier.flatMap { identifier in + self.entries.firstIndex { $0.orderIdentifier == identifier } + } ?? 0 + + self.collectionView.layoutIfNeeded() + self.controllerNavigationHighlightItemForControllerNavigator(at: IndexPath(item: item, section: 0)) + } + } + + @objc func restoreControllerNavigationHighlightAfterSettingsModeSwitch() { + } + + @objc func navigateByController(forward: Bool) { + if controllerNavigationReorderActive { + moveControllerNavigationHighlightedEntry(horizontalOffset: forward ? 1 : -1) + return + } + + controllerNavigationNavigateCollection(forward: forward) + } + + @objc func navigateByController(downward: Bool) { + if controllerNavigationReorderActive { + moveControllerNavigationHighlightedEntry(verticalOffset: downward ? 1 : -1) + return + } + + controllerNavigationNavigateCollection(downward: downward) + } + + @objc func uiWidgetActionForControllerNavigator(forward: Bool, from navigation: ControllerNavigationElement) { + } + + @objc func getNavigationElements() -> [ControllerNavigationElement] { + var elements: [ControllerNavigationElement] = [] + elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .rightStick : .leftStick, action: "focusNavigation")) + elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "execute")) + elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "doublePressPinExecute")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .y, action: "holdToReorder")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .y, action: "doublePressToDelete")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadLeft : .x, action: "addEntry")) + elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadDown : .b, action: "exit")) + return elements + } + + @objc func uiButtonActionForControllerNavigator(pressed: Bool, from navigation: ControllerNavigationElement) { + if navigation.action == "holdToReorder" { + handleControllerNavigationReorderHold(pressed: pressed) + return + } + + if navigation.action == "execute" { + handleControllerNavigationExecute(pressed: pressed) + return + } + + guard pressed else { return } + + if navigation.action == "addEntry" { + DispatchQueue.main.async{ [weak self] in + self?.addButtonTapped() + } + } + + if navigation.action == "exit" { + DispatchQueue.main.asyncAfter(deadline: .now()) { + self.dismiss(animated: true) + } + } + } + + private func handleControllerNavigationExecute(pressed: Bool) { + if pressed { + if controllerNavigationExecuteDismissWindowOpen { + closeControllerNavigationExecuteDismissWindow() + controllerNavigationExecuteSuppressNextRelease = true + return + } + + PublicUtils.runOnMain { [weak self] in + guard let self, + let indexPath = self.controllerNavigationCurrentIndexPathForControllerNavigator() else { + return + } + + self.activateEntry(at: indexPath, dismissAfterAction: false) + } + return + } + + guard !controllerNavigationExecuteSuppressNextRelease else { + controllerNavigationExecuteSuppressNextRelease = false + return + } + + guard !viewPinned else { return } + openControllerNavigationExecuteDismissWindow() + } + + private func openControllerNavigationExecuteDismissWindow() { + controllerNavigationExecuteDismissWindowOpen = true + controllerNavigationExecuteDismissToken += 1 + let token = controllerNavigationExecuteDismissToken + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self, + self.controllerNavigationExecuteDismissToken == token, + self.controllerNavigationExecuteDismissWindowOpen else { + return + } + + self.closeControllerNavigationExecuteDismissWindow() + self.dismiss(animated: false) + } + } + + private func closeControllerNavigationExecuteDismissWindow() { + controllerNavigationExecuteDismissWindowOpen = false + controllerNavigationExecuteDismissToken += 1 + } + + private func handleControllerNavigationReorderHold(pressed: Bool) { + if pressed { + if controllerNavigationDeleteDoublePressWindowOpen { + controllerNavigationDeleteDoublePressWindowOpen = false + controllerNavigationDeleteDoublePressToken += 1 + deleteControllerNavigationHighlightedCommand() + return + } + + guard let currentIndexPath = controllerNavigationCurrentIndexPathForControllerNavigator() else { return } + controllerNavigationReorderPressBeganAt = CACurrentMediaTime() + controllerNavigationReorderActive = true + beginControllerNavigationReorderSnapshot(at: currentIndexPath) + controllerNavigationSelectedIndexPath = currentIndexPath + return + } + + let pressDuration = controllerNavigationReorderPressBeganAt.map { CACurrentMediaTime() - $0 } ?? .greatestFiniteMagnitude + controllerNavigationReorderPressBeganAt = nil + + guard controllerNavigationReorderActive else { return } + controllerNavigationReorderActive = false + pendingControllerNavigationReorderMove = nil + controllerNavigationPersistOrderWhenReorderSettles = true + if pressDuration <= 0.2 { + openControllerNavigationDeleteDoublePressWindow() + } else { + closeControllerNavigationDeleteDoublePressWindow() + } + guard !controllerNavigationReorderUpdateInFlight else { return } + persistEntryOrder() + controllerNavigationPersistOrderWhenReorderSettles = false + endControllerNavigationReorderSnapshot() + restoreControllerNavigationHighlight() + persistControllerNavigationHighlight() + } + + private func openControllerNavigationDeleteDoublePressWindow() { + controllerNavigationDeleteDoublePressWindowOpen = true + controllerNavigationDeleteDoublePressToken += 1 + let token = controllerNavigationDeleteDoublePressToken + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self, + self.controllerNavigationDeleteDoublePressToken == token else { + return + } + + self.controllerNavigationDeleteDoublePressWindowOpen = false + } + } + + private func closeControllerNavigationDeleteDoublePressWindow() { + controllerNavigationDeleteDoublePressWindowOpen = false + controllerNavigationDeleteDoublePressToken += 1 + } + + private func deleteControllerNavigationHighlightedCommand() { + guard let indexPath = controllerNavigationCurrentIndexPathForControllerNavigator(), + entries.indices.contains(indexPath.item), + case .command(let offset, _) = entries[indexPath.item] else { + return + } + + closeControllerNavigationDeleteDoublePressWindow() + controllerNavigationReorderActive = false + pendingControllerNavigationReorderMove = nil + controllerNavigationPersistOrderWhenReorderSettles = false + endControllerNavigationReorderSnapshot() + clearAllControllerNavigationHighlightsForReorder() + + CommandManager.shared.deleteCommand(at: offset) + selectedCommandOffsets.remove(offset) + entries = entriesPreservingCurrentOrder(includeMissingEntries: false) + persistEntryOrder() + collectionView.reloadData() + updateEditingMode() + + guard !entries.isEmpty else { return } + + let targetItem = min(indexPath.item, entries.count - 1) + let targetIndexPath = IndexPath(item: targetItem, section: 0) + collectionView.layoutIfNeeded() + controllerNavigationHighlightItemForControllerNavigator(at: targetIndexPath) + persistControllerNavigationHighlight() + } + + private func moveControllerNavigationHighlightedEntry(horizontalOffset: Int) { + guard !controllerNavigationReorderUpdateInFlight else { + pendingControllerNavigationReorderMove = (horizontalOffset, 0) + return + } + + guard let currentIndexPath = controllerNavigationCurrentIndexPathForControllerNavigator() else { return } + let itemCount = collectionView.numberOfItems(inSection: 0) + guard itemCount > 1 else { return } + + let nextItem = min(max(currentIndexPath.item + horizontalOffset, 0), itemCount - 1) + guard nextItem != currentIndexPath.item else { return } + + moveControllerNavigationHighlightedEntry(from: currentIndexPath, to: IndexPath(item: nextItem, section: currentIndexPath.section)) + } + + private func moveControllerNavigationHighlightedEntry(verticalOffset: Int) { + guard !controllerNavigationReorderUpdateInFlight else { + pendingControllerNavigationReorderMove = (0, verticalOffset) + return + } + + guard let currentIndexPath = controllerNavigationCurrentIndexPathForControllerNavigator() else { return } + guard let destinationIndexPath = controllerNavigationReorderDestination(from: currentIndexPath, verticalOffset: verticalOffset) else { return } + + moveControllerNavigationHighlightedEntry(from: currentIndexPath, to: destinationIndexPath) + } + + private func controllerNavigationReorderDestination(from currentIndexPath: IndexPath, verticalOffset: Int) -> IndexPath? { + let itemCount = collectionView.numberOfItems(inSection: 0) + guard itemCount > 1, + let currentAttributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: currentIndexPath) else { + return nil + } + + let currentCenter = currentAttributes.center + let candidates = (0.. (indexPath: IndexPath, attributes: UICollectionViewLayoutAttributes)? in + let indexPath = IndexPath(item: item, section: 0) + guard indexPath != currentIndexPath, + let attributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) else { + return nil + } + + let isTargetDirection = verticalOffset > 0 + ? attributes.center.y > currentCenter.y + 1 + : attributes.center.y < currentCenter.y - 1 + return isTargetDirection ? (indexPath, attributes) : nil + } + + return candidates.min { lhs, rhs in + let lhsVerticalDistance = abs(lhs.attributes.center.y - currentCenter.y) + let rhsVerticalDistance = abs(rhs.attributes.center.y - currentCenter.y) + if abs(lhsVerticalDistance - rhsVerticalDistance) > 0.5 { + return lhsVerticalDistance < rhsVerticalDistance + } + + let lhsHorizontalDistance = abs(lhs.attributes.center.x - currentCenter.x) + let rhsHorizontalDistance = abs(rhs.attributes.center.x - currentCenter.x) + if abs(lhsHorizontalDistance - rhsHorizontalDistance) > 0.5 { + return lhsHorizontalDistance < rhsHorizontalDistance + } + + return verticalOffset > 0 + ? lhs.indexPath.item < rhs.indexPath.item + : lhs.indexPath.item > rhs.indexPath.item + }?.indexPath + } + + private func moveControllerNavigationHighlightedEntry(from sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + guard sourceIndexPath.section == 0, + destinationIndexPath.section == 0, + entries.indices.contains(sourceIndexPath.item), + entries.indices.contains(destinationIndexPath.item) else { + return + } + + controllerNavigationReorderUpdateInFlight = true + pendingControllerNavigationReorderMove = nil + clearAllControllerNavigationHighlightsForReorder() + controllerNavigationReorderHiddenIndexPath = sourceIndexPath + updateVisibleReorderHiddenCells() + + let movedEntry = entries.remove(at: sourceIndexPath.item) + entries.insert(movedEntry, at: destinationIndexPath.item) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.clearAllControllerNavigationHighlightsForReorder() + self.scrollToControllerNavigationReorderItemIfNeeded(at: destinationIndexPath, animated: true) + self.updateControllerNavigationReorderSnapshotFrame(to: destinationIndexPath, animated: true) + CATransaction.begin() + CATransaction.setAnimationDuration(0.07) + self.collectionView.performBatchUpdates { + self.collectionView.moveItem(at: sourceIndexPath, to: destinationIndexPath) + } completion: { [weak self] _ in + guard let self else { return } + self.clearAllControllerNavigationHighlightsForReorder() + self.scrollToControllerNavigationReorderItemIfNeeded(at: destinationIndexPath, animated: false) + self.updateControllerNavigationReorderSnapshotFrame(to: destinationIndexPath, animated: false) + self.controllerNavigationReorderHiddenIndexPath = destinationIndexPath + self.updateVisibleReorderHiddenCells() + self.controllerNavigationSelectedIndexPath = destinationIndexPath + self.controllerNavigationReorderUpdateInFlight = false + + if self.consumePendingControllerNavigationReorderMoveIfNeeded() { + return + } + + if self.controllerNavigationPersistOrderWhenReorderSettles { + self.persistEntryOrder() + self.controllerNavigationPersistOrderWhenReorderSettles = false + } + + guard !self.controllerNavigationReorderActive else { return } + self.endControllerNavigationReorderSnapshot() + self.controllerNavigationHighlightItemForControllerNavigator(at: destinationIndexPath) + self.persistControllerNavigationHighlight() + } + CATransaction.commit() + } + } + + private func beginControllerNavigationReorderSnapshot(at indexPath: IndexPath) { + clearAllControllerNavigationHighlightsForReorder() + controllerNavigationReorderSnapshotView?.removeFromSuperview() + + collectionView.layoutIfNeeded() + guard let cell = collectionView.cellForItem(at: indexPath), + let snapshot = cell.snapshotView(afterScreenUpdates: true) else { + return + } + + snapshot.frame = cell.frame + snapshot.layer.cornerRadius = Metrics.cardCornerRadius + snapshot.layer.masksToBounds = true + snapshot.layer.borderWidth = 3 + snapshot.layer.borderColor = UIColor.systemOrange.withAlphaComponent(ThemeManager.userInterfaceStyle() == .dark ? 0.8 : 0.97).cgColor + snapshot.alpha = 1 + snapshot.transform = .identity + snapshot.layer.zPosition = 1000 + + collectionView.addSubview(snapshot) + controllerNavigationReorderSnapshotView = snapshot + controllerNavigationReorderHiddenIndexPath = indexPath + updateVisibleReorderHiddenCells() + } + + private func scrollToControllerNavigationReorderItemIfNeeded(at indexPath: IndexPath, animated: Bool) { + guard indexPath.section < collectionView.numberOfSections, + indexPath.item >= 0, + indexPath.item < collectionView.numberOfItems(inSection: indexPath.section) else { + return + } + + collectionView.scrollToItem(at: indexPath, at: [.centeredHorizontally, .centeredVertically], animated: animated) + collectionView.layoutIfNeeded() + } + + private func updateControllerNavigationReorderSnapshotFrame(to indexPath: IndexPath, animated: Bool) { + guard let snapshot = controllerNavigationReorderSnapshotView else { return } + + collectionView.layoutIfNeeded() + guard let attributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) else { return } + + let updates = { + snapshot.center = attributes.center + snapshot.bounds.size = attributes.bounds.size + } + + if animated { + UIView.animate(withDuration: 0.07, delay: 0, options: [.beginFromCurrentState, .curveEaseInOut], animations: updates) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + self?.updateControllerNavigationReorderSnapshotFrame(to: indexPath, animated: false) + } + } else { + updates() + } + } + + private func endControllerNavigationReorderSnapshot() { + guard let snapshot = controllerNavigationReorderSnapshotView else { return } + controllerNavigationReorderSnapshotView = nil + controllerNavigationReorderHiddenIndexPath = nil + updateVisibleReorderHiddenCells() + UIView.animate(withDuration: 0.12, animations: { + snapshot.alpha = 0 + snapshot.transform = .identity + }) { _ in + snapshot.removeFromSuperview() + } + } + + private func clearAllControllerNavigationHighlightsForReorder() { + invalidateCollectionControllerNavigationHighlightForControllerNavigator() + collectionView.visibleCells.forEach { cell in + (cell as? ToolboxCardCell)?.clearControllerNavigationHighlight() + } + } + + private func updateVisibleReorderHiddenCells() { + collectionView.visibleCells.forEach { cell in + guard let indexPath = collectionView.indexPath(for: cell) else { + cell.isHidden = false + return + } + + cell.isHidden = indexPath == controllerNavigationReorderHiddenIndexPath + } + } + + @discardableResult + private func consumePendingControllerNavigationReorderMoveIfNeeded() -> Bool { + guard controllerNavigationReorderActive, + !controllerNavigationReorderUpdateInFlight, + let pendingMove = pendingControllerNavigationReorderMove else { + pendingControllerNavigationReorderMove = nil + return false + } + + pendingControllerNavigationReorderMove = nil + + if pendingMove.horizontal != 0 { + moveControllerNavigationHighlightedEntry(horizontalOffset: pendingMove.horizontal) + return true + } + + if pendingMove.vertical != 0 { + moveControllerNavigationHighlightedEntry(verticalOffset: pendingMove.vertical) + return true + } + + return false + } +} + +private final class ToolboxCardCell: UICollectionViewCell, ControllerNavigationHighlightTargetProviding { + enum Kind { + case special + case command + } + + static let reuseIdentifier = "ToolboxCardCell" + + private let symbolView = UIImageView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let checkmarkView = UIImageView() + private let disabledOverlay = UIView() + private let highlightOverlayView = UIView() + private var kind: Kind = .command + private var selectedForEditing = false + private var editing = false + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + } + + override var canBecomeFocused: Bool { + return true + } + + var controllerNavigationHighlightTargetView: UIView { + highlightOverlayView + } + + override func prepareForReuse() { + super.prepareForReuse() + isHidden = false + selectedForEditing = false + editing = false + disabledOverlay.isHidden = true + checkmarkView.isHidden = true + clearControllerNavigationHighlight() + contentView.transform = .identity + } + + func configure(title: String, subtitle: String, symbolName: String, kind: Kind, selectedForEditing: Bool, editing: Bool) { + self.kind = kind + self.selectedForEditing = selectedForEditing + self.editing = editing + + titleLabel.text = title + subtitleLabel.text = subtitle + if #available(iOS 13.0, tvOS 13.0, *) { + symbolView.image = UIImage(systemName: symbolName) ?? UIImage(named: symbolName) + } else { + symbolView.image = UIImage(named: symbolName) + } + disabledOverlay.isHidden = !(editing && kind == .special) + checkmarkView.isHidden = !(editing && kind == .command) + checkmarkView.alpha = selectedForEditing ? 1 : 0.28 + if #available(iOS 13.0, tvOS 13.0, *) { + checkmarkView.image = UIImage(systemName: selectedForEditing ? "checkmark.circle.fill" : "circle") + } else { + checkmarkView.image = nil + } + + applyTheme() + } + + func applyTheme() { + let darkMode = traitCollection.userInterfaceStyle == .dark + let special = kind == .special + + if selectedForEditing { + contentView.backgroundColor = UIColor.systemOrange.withAlphaComponent(darkMode ? 0.34 : 0.24) + contentView.layer.borderColor = UIColor.systemOrange.withAlphaComponent(0.90).cgColor + contentView.layer.borderWidth = 1.5 + } else { + contentView.backgroundColor = special + ? (darkMode ? UIColor(white: 1, alpha: 0.12) : UIColor(white: 1, alpha: 0.72)) + // : (darkMode ? UIColor(red: 0.27, green: 0.23, blue: 0.13, alpha: 0.90) : UIColor(red: 1.00, green: 0.94, blue: 0.62, alpha: 0.88)) + : .clear + contentView.layer.borderColor = special + ? (darkMode ? UIColor(white: 1, alpha: 0.18).cgColor : UIColor(white: 1, alpha: 0.88).cgColor) + : UIColor.systemOrange.withAlphaComponent(darkMode ? 0.45 : 0.6).cgColor + contentView.layer.borderWidth = special ? 1.0 : 1.2 + } + + titleLabel.textColor = darkMode ? .white : UIColor(white: 0.10, alpha: 1) + subtitleLabel.textColor = darkMode ? UIColor(white: 1, alpha: 0.56) : UIColor(white: 0, alpha: 0.50) + symbolView.tintColor = special + ? (darkMode ? UIColor(white: 1, alpha: 0.82) : UIColor(white: 0.12, alpha: 0.72)) + : UIColor.systemOrange + checkmarkView.tintColor = UIColor.systemOrange + disabledOverlay.backgroundColor = darkMode ? UIColor(white: 0, alpha: 0.36) : UIColor(white: 1, alpha: 0.48) + } + + func controllerNavigationHighlightDidClear() { + applyTheme() + } + + func controllerNavigationHighlightDidApply() { + guard kind == .special else { return } + contentView.layer.borderWidth = 0 + contentView.layer.borderColor = nil + } + + func clearControllerNavigationHighlight() { + highlightOverlayView.layer.borderWidth = 0 + highlightOverlayView.layer.borderColor = nil + applyTheme() + } + + func flash() { + UIView.animate(withDuration: 0.08, animations: { + self.contentView.transform = CGAffineTransform(scaleX: 0.97, y: 0.97) + }) { _ in + UIView.animate(withDuration: 0.16) { + self.contentView.transform = .identity + } + } + } + + private func setupViews() { + contentView.layer.cornerRadius = 16 + contentView.layer.masksToBounds = true + + symbolView.translatesAutoresizingMaskIntoConstraints = false + symbolView.contentMode = .scaleAspectFit + + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 13 : 15, weight: .semibold) + titleLabel.numberOfLines = 2 + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.66 + titleLabel.lineBreakMode = .byTruncatingTail + + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + if #available(iOS 13.0, tvOS 13.0, *) { + subtitleLabel.font = UIFont.monospacedSystemFont(ofSize: PublicUtils.isIPhone ? 10 : 12, weight: .medium) + } else { + subtitleLabel.font = UIFont.systemFont(ofSize: PublicUtils.isIPhone ? 10 : 12, weight: .medium) + } + subtitleLabel.numberOfLines = 1 + subtitleLabel.adjustsFontSizeToFitWidth = true + subtitleLabel.minimumScaleFactor = 0.6 + + checkmarkView.translatesAutoresizingMaskIntoConstraints = false + disabledOverlay.translatesAutoresizingMaskIntoConstraints = false + disabledOverlay.isUserInteractionEnabled = false + highlightOverlayView.translatesAutoresizingMaskIntoConstraints = false + highlightOverlayView.isUserInteractionEnabled = false + highlightOverlayView.backgroundColor = .clear + highlightOverlayView.layer.cornerRadius = 16 + highlightOverlayView.layer.masksToBounds = true + + contentView.addSubview(symbolView) + contentView.addSubview(titleLabel) + contentView.addSubview(subtitleLabel) + contentView.addSubview(checkmarkView) + contentView.addSubview(disabledOverlay) + contentView.addSubview(highlightOverlayView) + + let iconSize: CGFloat = PublicUtils.isIPhone ? 24 : 30 + let sidePadding: CGFloat = PublicUtils.isIPhone ? 10 : 14 + let topPadding: CGFloat = PublicUtils.isIPhone ? 9 : 12 + + NSLayoutConstraint.activate([ + symbolView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: sidePadding), + symbolView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: topPadding), + symbolView.widthAnchor.constraint(equalToConstant: iconSize), + symbolView.heightAnchor.constraint(equalToConstant: iconSize), + + checkmarkView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -sidePadding), + checkmarkView.centerYAnchor.constraint(equalTo: symbolView.centerYAnchor), + checkmarkView.widthAnchor.constraint(equalToConstant: iconSize), + checkmarkView.heightAnchor.constraint(equalToConstant: iconSize), + + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: sidePadding), + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -sidePadding), + titleLabel.topAnchor.constraint(equalTo: symbolView.bottomAnchor, constant: PublicUtils.isIPhone ? 6 : 10), + + subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 3), + subtitleLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -topPadding), + + disabledOverlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + disabledOverlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + disabledOverlay.topAnchor.constraint(equalTo: contentView.topAnchor), + disabledOverlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + highlightOverlayView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + highlightOverlayView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + highlightOverlayView.topAnchor.constraint(equalTo: contentView.topAnchor), + highlightOverlayView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + applyTheme() + } + + #if os(tvOS) + override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + super.didUpdateFocus(in: context, with: coordinator) + coordinator.addCoordinatedAnimations { + self.contentView.transform = self.isFocused ? CGAffineTransform(scaleX: 1.06, y: 1.06) : .identity + } + } + #endif +} diff --git a/VoidLinkPreview/RadialMenuView.swift b/VoidLinkPreview/RadialMenuView.swift index 09e632e7..80ea6ca7 100644 --- a/VoidLinkPreview/RadialMenuView.swift +++ b/VoidLinkPreview/RadialMenuView.swift @@ -25,6 +25,7 @@ import UIKit case more case navigationSettings case exit + case shortcuts } @objc public enum RadialMenuState:Int { diff --git a/VoidLinkPreview/WidgetPickerViewController.swift b/VoidLinkPreview/WidgetPickerViewController.swift index df083415..ac0687ee 100644 --- a/VoidLinkPreview/WidgetPickerViewController.swift +++ b/VoidLinkPreview/WidgetPickerViewController.swift @@ -221,4 +221,16 @@ public final class WidgetPickerViewController: UIViewController { hostViewController.setNeedsUpdateOfScreenEdgesDeferringSystemGestures() hostViewController.setNeedsUpdateOfHomeIndicatorAutoHidden() } + + @objc func getNavigationElements() -> [ControllerNavigationElement] { + var elements: [ControllerNavigationElement] = [] + elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .rightStick : .leftStick, action: "focusNavigation")) + elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "execute")) + elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadRight : .a, action: "doublePressPinExecute")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .y, action: "holdToReorder")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadUp : .y, action: "doublePressToDelete")) + elements.append(ControllerNavigationElement(control:ControllerNavigator.radialMenuButtonPosition == .left ? .dpadLeft : .x, action: "addEntry")) + elements.append(ControllerNavigationElement(control: ControllerNavigator.radialMenuButtonPosition == .left ? .dpadDown : .b, action: "exit")) + return elements + } }