From e0aff4b532218e773d4346e138d025f71836c601 Mon Sep 17 00:00:00 2001 From: True Zhuanjia Date: Thu, 30 Jul 2026 14:14:33 +0800 Subject: [PATCH] - genshin touch control enhancement preview for settings menu - controller navigation & first connection tips - enhancedTouchPreview bug fix --- VoidLink.xcodeproj/project.pbxproj | 4 + VoidLink/Input/ControllerNavigator.swift | 4 +- VoidLink/Input/ControllerUtil.swift | 12 + VoidLink/Localization/Localizable.xcstrings | 199 +++++++++- VoidLink/Utility/GenericUtils.swift | 28 ++ VoidLink/Utility/PublicExtensions.swift | 4 +- .../Table/ProfileSelectorViewController.swift | 3 + .../SettingsTouchVelocityPreview.swift | 370 ++++++++++++++++++ .../ViewControllers/SettingsViewController.m | 18 + .../ToolboxViewController.swift | 22 +- 10 files changed, 656 insertions(+), 8 deletions(-) create mode 100644 VoidLink/ViewControllers/SettingsTouchVelocityPreview.swift diff --git a/VoidLink.xcodeproj/project.pbxproj b/VoidLink.xcodeproj/project.pbxproj index 668b4e9e..3dad69f3 100644 --- a/VoidLink.xcodeproj/project.pbxproj +++ b/VoidLink.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ 2AC1E3832F507D80004F76C0 /* GenericUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AC1E3822F507D80004F76C0 /* GenericUtils.swift */; }; 2AC1EAFB2F92260F004EDA80 /* UIStackExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AC1EAFA2F922602004EDA80 /* UIStackExtension.swift */; }; 2AC1EAFC2F922889004EDA80 /* UIStackExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AC1EAFA2F922602004EDA80 /* UIStackExtension.swift */; }; + 2B7A10022FFB000100000001 /* SettingsTouchVelocityPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7A10012FFB000100000001 /* SettingsTouchVelocityPreview.swift */; }; 3A4A3A112F0C000100000001 /* ProfileSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4A3A102F0C000100000001 /* ProfileSelectorViewController.swift */; }; 3A4A3A132F0C000100000003 /* LayoutOnScreenControlsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A4A3A122F0C000100000002 /* LayoutOnScreenControlsViewController.swift */; }; 840751B32BF49C050096B657 /* CustomTapGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 840751B22BF49C050096B657 /* CustomTapGestureRecognizer.m */; }; @@ -218,6 +219,7 @@ 2A7114002F5A6C65001D852D /* GamepadNavigationIllustrationHud.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamepadNavigationIllustrationHud.swift; sourceTree = ""; }; 2AC1E3822F507D80004F76C0 /* GenericUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericUtils.swift; sourceTree = ""; }; 2AC1EAFA2F922602004EDA80 /* UIStackExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIStackExtension.swift; sourceTree = ""; }; + 2B7A10012FFB000100000001 /* SettingsTouchVelocityPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTouchVelocityPreview.swift; sourceTree = ""; }; 3A4A3A102F0C000100000001 /* ProfileSelectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSelectorViewController.swift; sourceTree = ""; }; 3A4A3A122F0C000100000002 /* LayoutOnScreenControlsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutOnScreenControlsViewController.swift; sourceTree = ""; }; 693B3A9A218638CD00982F7B /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -864,6 +866,7 @@ FB89462719F646E200339C8A /* StreamFrameViewController.m */, FBD3494E19FF2174002D2A60 /* SettingsViewController.h */, FBD3494F19FF2174002D2A60 /* SettingsViewController.m */, + 2B7A10012FFB000100000001 /* SettingsTouchVelocityPreview.swift */, 2AC1EAFA2F922602004EDA80 /* UIStackExtension.swift */, FB4A23B61A9D3637004D2EF2 /* LoadingFrameViewController.h */, FB4A23B71A9D3637004D2EF2 /* LoadingFrameViewController.m */, @@ -1188,6 +1191,7 @@ 97E20DEA2E216532000D5DCA /* SceneDelegate.m in Sources */, 1817B61E2E6FD04D000D938D /* SafeTimer.swift in Sources */, FBD3495019FF2174002D2A60 /* SettingsViewController.m in Sources */, + 2B7A10022FFB000100000001 /* SettingsTouchVelocityPreview.swift in Sources */, 843CC7F02C1E757D00BBC475 /* NativeTouchPointer.m in Sources */, 2A7113FD2F5A6C65001D852D /* ThemeManager.swift in Sources */, FB89462C19F646E200339C8A /* HttpManager.m in Sources */, diff --git a/VoidLink/Input/ControllerNavigator.swift b/VoidLink/Input/ControllerNavigator.swift index a5325fef..03f42703 100644 --- a/VoidLink/Input/ControllerNavigator.swift +++ b/VoidLink/Input/ControllerNavigator.swift @@ -338,6 +338,7 @@ final class ControllerNavigator: NSObject { } static func updateUINavigationDelegateState() { + ControllerUtil.disableSysGestures(ControllerUtil.primaryGCController) if uiNavigationDelegate is StreamFrameViewController { if radialMenuState == .mouseModeEnabled { startControllerMouse() @@ -2273,7 +2274,8 @@ extension ControllerCollectionNavigationDelegate { extension MainFrameViewController { func forceLaunchApp(_ app: TemporaryApp?){ - if let currentRunningApp = self.findRunningApp(app?.host) { + guard let app = app else { return } + if let currentRunningApp = self.findRunningApp(app.host) { if currentRunningApp !== app { self.quitRunningAppAndStart(app) } diff --git a/VoidLink/Input/ControllerUtil.swift b/VoidLink/Input/ControllerUtil.swift index 19fe37ca..a8c80153 100644 --- a/VoidLink/Input/ControllerUtil.swift +++ b/VoidLink/Input/ControllerUtil.swift @@ -466,6 +466,18 @@ import Foundation guard GCController.controllers().count == 1 else { return } + if let mainFrameVC = delegate as? MainFrameViewController { + let vc = mainFrameVC.isStreaming() ? StreamFrameViewController.sharedInstance() : mainFrameVC + GenericUtils.handleFirstGamepadConnection(in: vc) { + setGCControllerToPrimary(controller) + return + } + } + + setGCControllerToPrimary(controller) + } + + private static func setGCControllerToPrimary(_ controller: GCController) { primaryGCController = controller _ = buildMapping(for: controller, swapABXY: false) disableSysGestures(controller) diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index 7c7a4dc8..16bf797a 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -536,6 +536,35 @@ } } }, + "%.0f%% velocity" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f%% velocity" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f%% 移速缩放" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f%% 移速縮放" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.0f%% 移速縮放" + } + } + } + }, "%@ is currently running" : { "extractionState" : "manual", "localizations" : { @@ -6269,6 +6298,35 @@ } } }, + "controllerNavigationTip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VoidLink now supports global controller navigation! Go to [Settings Menu] → [Controller] section → turn [Controller Navigation] off and on again to customize radial menu buttons and controller mouse mode." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "VoidLink已支持全局手柄导航!前往 [设置菜单] → [手柄] 分区 → 关闭再打开 [手柄导航],即可自定义环形菜单按键与手柄鼠标模式。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "VoidLink 已支援全域手把導航!前往 [設定選單] → [手把] 分區 → 關閉再開啟 [手把導航],即可自訂環形選單按鍵與手把滑鼠模式。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "VoidLink 已支援全域手把導航!前往 [設定選單] → [手把] 分區 → 關閉再開啟 [手把導航],即可自訂環形選單按鍵與手把滑鼠模式。" + } + } + } + }, "Create" : { "extractionState" : "manual", "localizations" : { @@ -6678,6 +6736,35 @@ } } }, + "descendants" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "descendants" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "衍生版" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "衍生版" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "衍生版" + } + } + } + }, "Detailed Tutorial" : { "extractionState" : "manual", "localizations" : { @@ -7750,6 +7837,35 @@ } } }, + "Enhanced Touch Control for Genshin Game Streaming" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enhanced Touch Control for Genshin Game Streaming" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "原神串流增强触控" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "原神串流增強觸控" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "原神串流增強觸控" + } + } + } + }, "Enter a new command and alias" : { "extractionState" : "manual", "localizations" : { @@ -12922,6 +13038,35 @@ } } }, + "Originally designed & coded by True Zhuanjia with pride" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Originally designed & coded by True Zhuanjia with pride" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "由 True砖家@Bilibili 自豪地原创设计并编码" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "由 True砖家@Bilibili 自豪地原創設計並編碼" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "由 True砖家@Bilibili 自豪地原創設計並編碼" + } + } + } + }, "Others" : { "extractionState" : "manual", "localizations" : { @@ -13083,6 +13228,35 @@ } } }, + "passthrough" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "passthrough" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "坐标透传" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "座標透傳" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "座標透傳" + } + } + } + }, "Pen Pressure Curve" : { "extractionState" : "manual", "localizations" : { @@ -17252,6 +17426,29 @@ } } }, + "Tap to learn more" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击了解更多" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "點擊了解更多" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "點擊了解更多" + } + } + } + }, "Tap to toggle" : { "extractionState" : "manual", "localizations" : { @@ -19046,4 +19243,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/VoidLink/Utility/GenericUtils.swift b/VoidLink/Utility/GenericUtils.swift index 17699e02..172714f4 100644 --- a/VoidLink/Utility/GenericUtils.swift +++ b/VoidLink/Utility/GenericUtils.swift @@ -442,6 +442,34 @@ import UIKit return false } + @objc public static func isFirstConnectingGamepad() -> Bool { + let key = "isFirstConnectingGamepad20260730" + let defaults = UserDefaults.standard + let launchedBefore = defaults.bool(forKey: key) + if !launchedBefore { + defaults.set(true, forKey: key) + return true + } + return false + } + @objc public static func handleFirstGamepadConnection(in vc: UIViewController?, handler: @escaping () -> Void) { + if isFirstConnectingGamepad() { + AlertControllerUtil.showAlert( + in: vc, + title: "Tips".localized, + message: "controllerNavigationTip".localized, + withCancel: false, + buttonTitle: "Got it!".localized, + countdown: 6, + completion: { + handler() + } + ) + } + } + + + @objc public static func isFirstStreamingOnMac() -> Bool { if !PublicUtils.isRunningOnMacAsiPadApp {return false} let key = "hasStreamedOnMac" diff --git a/VoidLink/Utility/PublicExtensions.swift b/VoidLink/Utility/PublicExtensions.swift index 44a2a17d..689ea133 100644 --- a/VoidLink/Utility/PublicExtensions.swift +++ b/VoidLink/Utility/PublicExtensions.swift @@ -10,9 +10,9 @@ import UIKit extension UIViewController { var hasNoPresentedVC: Bool { - let hasPresentedVC = self.presentedViewController == nil && self.view.window != nil + let hasNoPresentedVC = self.presentedViewController == nil && self.view.window != nil // let presentedVC = self.presentedViewController - return hasPresentedVC + return hasNoPresentedVC } } diff --git a/VoidLink/ViewControllers/CustomOSCViewControl/Table/ProfileSelectorViewController.swift b/VoidLink/ViewControllers/CustomOSCViewControl/Table/ProfileSelectorViewController.swift index 76416033..eaa74954 100644 --- a/VoidLink/ViewControllers/CustomOSCViewControl/Table/ProfileSelectorViewController.swift +++ b/VoidLink/ViewControllers/CustomOSCViewControl/Table/ProfileSelectorViewController.swift @@ -99,6 +99,9 @@ private final class ProfileCollectionViewCell: UICollectionViewCell, ControllerN } } + override var canBecomeFocused: Bool { + return false + } } @objc(FileOperation) diff --git a/VoidLink/ViewControllers/SettingsTouchVelocityPreview.swift b/VoidLink/ViewControllers/SettingsTouchVelocityPreview.swift new file mode 100644 index 00000000..91040039 --- /dev/null +++ b/VoidLink/ViewControllers/SettingsTouchVelocityPreview.swift @@ -0,0 +1,370 @@ +// +// SettingsTouchVelocityPreview.swift +// VoidLink +// +// Created by True砖家 on 2026/7/30. +// Copyright © 2026 True砖家 on Bilibili. All rights reserved. +// + +import ObjectiveC +import UIKit + +private var touchVelocityPreviewViewKey: UInt8 = 0 +private var touchVelocityPreviewDismissWorkItemKey: UInt8 = 0 +private let touchVelocityPreviewScreenAspectRatio: CGFloat = 1.3333 + +@available(iOS 13.0, *) +private final class TouchVelocityPreviewView: UIView { + static let horizontalScreenInset: CGFloat = 16 + static let topPadding: CGFloat = 7 + static let titleHeight: CGFloat = 22 + static let titleScreenGap: CGFloat = 2 + static let footerGap: CGFloat = 6 + static let footerHeight: CGFloat = 40 + static let bottomPadding: CGFloat = 2 + static let heightExtra = topPadding + + titleHeight + + titleScreenGap + + footerGap + + footerHeight + + bottomPadding + - horizontalScreenInset * 2 / touchVelocityPreviewScreenAspectRatio + static let preferredWidth: CGFloat = PublicUtils.isIPhone ? 280 : 360 + static let preferredHeight = preferredWidth / touchVelocityPreviewScreenAspectRatio + heightExtra + + var dividerPercent: CGFloat = 50 { + didSet { setNeedsDisplay() } + } + + var velocityPercent: CGFloat = 100 { + didSet { setNeedsDisplay() } + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + isOpaque = false + isUserInteractionEnabled = true + layer.cornerRadius = 14 + layer.cornerCurve = .continuous + layer.masksToBounds = true + layer.borderWidth = 1 + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(openUrl)) + addGestureRecognizer(tapGesture) + } + + @objc private func openUrl() { + PublicUtils.openUrl("https://www.bilibili.com/video/BV1A1421r7qA") + } + + override func draw(_ rect: CGRect) { + let isDark = ThemeManager.userInterfaceStyle() == .dark + let backgroundColor = isDark + ? UIColor(white: 0.07, alpha: 0.9) + : UIColor.white.withAlphaComponent(0.94) + let borderColor = ThemeManager.appPrimaryColor.withAlphaComponent(isDark ? 0.34 : 0.24) + let lineColor = ThemeManager.textColor.withAlphaComponent(isDark ? 0.32 : 0.22) + let passthroughColor = UIColor.systemTeal.withAlphaComponent(isDark ? 0.22 : 0.16) + let velocityColor = UIColor.systemIndigo.withAlphaComponent(isDark ? 0.20 : 0.13) + let controlColor = ThemeManager.textColor.withAlphaComponent(isDark ? 0.82 : 0.66) + let accentColor = ThemeManager.appPrimaryColor + let secondaryAccentColor = UIColor(red: 0.18, green: 0.82, blue: 0.95, alpha: 1) + layer.borderColor = borderColor.cgColor + + guard let context = UIGraphicsGetCurrentContext() else { return } + context.setFillColor(backgroundColor.cgColor) + context.fill(bounds) + + let titleRect = CGRect( + x: 12, + y: Self.topPadding, + width: bounds.width - 24, + height: Self.titleHeight + ) + drawPreviewTitle(in: titleRect, textColor: ThemeManager.textColor) + + let screenTop = titleRect.maxY + Self.titleScreenGap + let availableScreenHeight = max(bounds.height - screenTop - Self.footerGap - Self.footerHeight - Self.bottomPadding, 1) + let availableScreenWidth = bounds.width - Self.horizontalScreenInset * 2 + let screenWidth = min(availableScreenWidth, availableScreenHeight * touchVelocityPreviewScreenAspectRatio) + let screenHeight = screenWidth / touchVelocityPreviewScreenAspectRatio + let screenRect = CGRect( + x: bounds.midX - screenWidth / 2, + y: screenTop, + width: screenWidth, + height: screenHeight + ) + let screenPath = UIBezierPath(roundedRect: screenRect, cornerRadius: 10) + context.saveGState() + screenPath.addClip() + + let dividerX = screenRect.minX + min(max(dividerPercent, 0), 100) / 100 * screenRect.width + context.setFillColor(passthroughColor.cgColor) + context.fill(CGRect(x: screenRect.minX, y: screenRect.minY, width: dividerX - screenRect.minX, height: screenRect.height)) + context.setFillColor(velocityColor.cgColor) + context.fill(CGRect(x: dividerX, y: screenRect.minY, width: screenRect.maxX - dividerX, height: screenRect.height)) + + drawDirectionWheel(in: screenRect, color: controlColor, accentColor: secondaryAccentColor) + drawCharacterSelector(in: screenRect, color: controlColor, accentColor: accentColor) + drawActionButtons(in: screenRect, color: controlColor, accentColor: accentColor) + + context.restoreGState() + + screenPath.lineWidth = 1 + lineColor.setStroke() + screenPath.stroke() + + context.saveGState() + context.setLineDash(phase: 0, lengths: [6, 5]) + context.setStrokeColor(accentColor.withAlphaComponent(0.9).cgColor) + context.setLineWidth(2) + context.move(to: CGPoint(x: dividerX, y: screenRect.minY + 2)) + context.addLine(to: CGPoint(x: dividerX, y: screenRect.maxY - 2)) + context.strokePath() + context.restoreGState() + + drawRegionLabels(screenRect: screenRect, dividerX: dividerX, textColor: ThemeManager.textColor) + drawPreviewFooter(in: CGRect(x: 12, y: screenRect.maxY + Self.footerGap, width: bounds.width - 24, height: Self.footerHeight), textColor: ThemeManager.textColor) + } + + private func drawPreviewTitle(in rect: CGRect, textColor: UIColor) { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + paragraph.lineBreakMode = .byTruncatingTail + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 14.5, weight: .semibold), + .foregroundColor: textColor.withAlphaComponent(0.88), + .paragraphStyle: paragraph + ] + ("Enhanced Touch Control for Genshin Game Streaming".localized as NSString).draw(in: rect, withAttributes: attributes) + } + + private func drawPreviewFooter(in rect: CGRect, textColor: UIColor) { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + paragraph.lineBreakMode = .byTruncatingTail + + let firstLineAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 13, weight: .semibold), + .foregroundColor: textColor.withAlphaComponent(0.82), + .paragraphStyle: paragraph + ] + ("Originally designed & coded by True Zhuanjia with pride".localized as NSString).draw( + in: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: 20), + withAttributes: firstLineAttributes + ) + + let secondLine = "Tap to learn more".localized + let secondLineAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 13, weight: .semibold), + .foregroundColor: textColor.withAlphaComponent(0.82), + .paragraphStyle: paragraph + ] + (secondLine as NSString).draw( + in: CGRect(x: rect.minX, y: rect.minY + 20, width: rect.width, height: 20), + withAttributes: secondLineAttributes + ) + } + + private func drawRegionLabels(screenRect: CGRect, dividerX: CGFloat, textColor: UIColor) { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + paragraph.lineBreakMode = .byTruncatingTail + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 13, weight: .semibold), + .foregroundColor: textColor.withAlphaComponent(0.82), + .paragraphStyle: paragraph + ] + + let leftRect = CGRect( + x: screenRect.minX + 8, + y: screenRect.minY + 10, + width: max(dividerX - screenRect.minX - 16, 0), + height: 18 + ) + let rightRect = CGRect( + x: dividerX + 8, + y: screenRect.minY + 10, + width: max(screenRect.maxX - dividerX - 16, 0), + height: 18 + ) + + if leftRect.width >= 72 { + ("passthrough".localized as NSString).draw(in: leftRect, withAttributes: attributes) + } + if rightRect.width >= 80 { + let velocityText = LocalizationHelper.localizedString(forKey: "%.0f%% velocity", velocityPercent) + (velocityText as NSString).draw(in: rightRect, withAttributes: attributes) + } + } + + private func drawDirectionWheel(in rect: CGRect, color: UIColor, accentColor: UIColor) { + let base = min(rect.width, rect.height) + let center = CGPoint(x: rect.minX + rect.width * 0.18, y: rect.maxY - base * 0.20) + let radius = base * 0.13 + color.withAlphaComponent(0.18).setStroke() + let outer = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: .pi * 2, clockwise: true) + outer.lineWidth = 2 + outer.stroke() + + accentColor.withAlphaComponent(0.9).setStroke() + let arc = UIBezierPath(arcCenter: center, radius: radius * 0.72, startAngle: -.pi * 0.8, endAngle: -.pi * 0.18, clockwise: true) + arc.lineWidth = 5 + arc.lineCapStyle = .round + arc.stroke() + + color.withAlphaComponent(0.52).setStroke() + let hub = UIBezierPath(ovalIn: CGRect(x: center.x - radius * 0.17, y: center.y - radius * 0.17, width: radius * 0.34, height: radius * 0.34)) + hub.lineWidth = 2 + hub.stroke() + } + + private func drawCharacterSelector(in rect: CGRect, color: UIColor, accentColor: UIColor) { + let rowWidth = rect.width * 0.14 + let rowHeight = rect.height * 0.07 + let startX = rect.maxX - rowWidth - rect.width * 0.05 + let startY = rect.minY + rect.height * 0.22 + + for index in 0..<3 { + let y = startY + CGFloat(index) * rowHeight * 1.42 + let lineRect = CGRect(x: startX, y: y + rowHeight * 0.36, width: rowWidth * 0.6, height: rowHeight * 0.18) + let line = UIBezierPath(roundedRect: lineRect, cornerRadius: lineRect.height / 2) + color.withAlphaComponent(0.34).setFill() + line.fill() + + let portraitRect = CGRect(x: startX + rowWidth * 0.68, y: y, width: rowHeight, height: rowHeight) + color.withAlphaComponent(0.38).setStroke() + let portrait = UIBezierPath(ovalIn: portraitRect) + portrait.lineWidth = 2 + portrait.stroke() + } + } + + private func drawActionButtons(in rect: CGRect, color: UIColor, accentColor: UIColor) { + let base = rect.width + let center = CGPoint(x: rect.maxX - base * 0.19, y: rect.maxY - base * 0.16) + let buttons: [(CGPoint, CGFloat)] = [ + (center, base * 0.055), + (CGPoint(x: center.x + base * 0.11, y: center.y - base * 0.077), base * 0.05), + (CGPoint(x: center.x + base * 0.11, y: center.y + base * 0.068), base * 0.05), + (CGPoint(x: center.x - base * 0.11, y: center.y + base * 0.068), base * 0.05) + ] + + for (index, button) in buttons.enumerated() { + let rect = CGRect(x: button.0.x - button.1, y: button.0.y - button.1, width: button.1 * 2, height: button.1 * 2) + let path = UIBezierPath(ovalIn: rect) + color.withAlphaComponent(index == 0 ? 0.44 : 0.32).setStroke() + path.lineWidth = index == 0 ? 3 : 2 + path.stroke() + + let iconRect = rect.insetBy(dx: button.1 * 0.42, dy: button.1 * 0.42) + let icon = UIBezierPath(ovalIn: iconRect) + (index == 0 ? accentColor : color).withAlphaComponent(index == 0 ? 0.78 : 0.55).setFill() + icon.fill() + } + } +} + +@available(iOS 13.0, *) +extension SettingsViewController { + private var touchVelocityPreviewView: TouchVelocityPreviewView? { + get { + objc_getAssociatedObject(self, &touchVelocityPreviewViewKey) as? TouchVelocityPreviewView + } + set { + objc_setAssociatedObject(self, &touchVelocityPreviewViewKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private var touchVelocityPreviewDismissWorkItem: DispatchWorkItem? { + get { + objc_getAssociatedObject(self, &touchVelocityPreviewDismissWorkItemKey) as? DispatchWorkItem + } + set { + objc_setAssociatedObject(self, &touchVelocityPreviewDismissWorkItemKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + @objc(showTouchVelocityPreviewWithDividerPercent:velocityPercent:) + func showTouchVelocityPreview(dividerPercent: CGFloat, velocityPercent: CGFloat) { + PublicUtils.runOnMain { [weak self] in + self?.showTouchVelocityPreviewOnMain(dividerPercent: dividerPercent, velocityPercent: velocityPercent) + } + } + + private func showTouchVelocityPreviewOnMain(dividerPercent: CGFloat, velocityPercent: CGFloat) { + let previewView = touchVelocityPreviewView ?? TouchVelocityPreviewView() + touchVelocityPreviewView = previewView + previewView.dividerPercent = dividerPercent + previewView.velocityPercent = velocityPercent + previewView.translatesAutoresizingMaskIntoConstraints = false + + if previewView.superview == nil { + previewView.alpha = 0 + guard let containerView = view.window ?? view else { return } + containerView.addSubview(previewView) + let containerSafeArea = containerView.safeAreaLayoutGuide + var contraints = [ + previewView.centerYAnchor.constraint(equalTo: containerSafeArea.centerYAnchor), + previewView.widthAnchor.constraint(equalToConstant: TouchVelocityPreviewView.preferredWidth), + previewView.heightAnchor.constraint(equalToConstant: TouchVelocityPreviewView.preferredHeight) + ] + + if PublicUtils.isIPhone { + contraints.append(previewView.trailingAnchor.constraint(equalTo: containerSafeArea.trailingAnchor, constant: previewView.bounds.width-50)) + } + else { + contraints.append(previewView.centerXAnchor.constraint(equalTo: containerSafeArea.centerXAnchor, constant: 80)) + } + + NSLayoutConstraint.activate(contraints) + } + + previewView.superview?.bringSubviewToFront(previewView) + UIView.animate(withDuration: 0.12) { + previewView.alpha = 1 + } + + } + + @objc(cancelTouchVelocityPreviewDismiss) + func cancelTouchVelocityPreviewDismiss() { + PublicUtils.runOnMain { [weak self] in + self?.touchVelocityPreviewDismissWorkItem?.cancel() + self?.touchVelocityPreviewDismissWorkItem = nil + } + } + + @objc(scheduleTouchVelocityPreviewDismiss) + func scheduleTouchVelocityPreviewDismiss() { + PublicUtils.runOnMain { [weak self] in + self?.scheduleTouchVelocityPreviewDismissOnMain() + } + } + + private func scheduleTouchVelocityPreviewDismissOnMain() { + guard let previewView = touchVelocityPreviewView else { return } + touchVelocityPreviewDismissWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self, weak previewView] in + UIView.animate(withDuration: 0.18, animations: { + previewView?.alpha = 0 + }, completion: { _ in + previewView?.removeFromSuperview() + if self?.touchVelocityPreviewView === previewView { + self?.touchVelocityPreviewView = nil + } + }) + } + touchVelocityPreviewDismissWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem) + } +} diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index 3c762ef0..7cc6c7a1 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -2411,9 +2411,17 @@ BOOL isCustomResolution(int resolutionSelected) { // pointer veloc setting, will be enable/disabled by touchMode [self.pointerVelocityModeDividerSlider addTarget:self action:@selector(pointerVelocityModeDividerSliderMoved:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved. + if (@available(iOS 13.0, *)) { + [self.pointerVelocityModeDividerSlider addTarget:self action:@selector(cancelTouchVelocityPreviewDismiss) forControlEvents:UIControlEventTouchDown]; + [self.pointerVelocityModeDividerSlider addTarget:self action:@selector(scheduleTouchVelocityPreviewDismiss) forControlEvents:(UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchCancel)]; + } // init pointer veloc setting, will be enable/disabled by touchMode [self.touchPointerVelocityFactorSlider addTarget:self action:@selector(touchPointerVelocityFactorSliderMoved:) forControlEvents:(UIControlEventValueChanged)]; // Update label display when slider is being moved. + if (@available(iOS 13.0, *)) { + [self.touchPointerVelocityFactorSlider addTarget:self action:@selector(cancelTouchVelocityPreviewDismiss) forControlEvents:UIControlEventTouchDown]; + [self.touchPointerVelocityFactorSlider addTarget:self action:@selector(scheduleTouchVelocityPreviewDismiss) forControlEvents:(UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchCancel)]; + } // async native touch event // self.asyncNativeTouchPrioritySelector.selectedSegmentIndex = currentSettings.asyncNativeTouchPriority.intValue; // load old setting of asyncNativeTouchPriority @@ -3264,10 +3272,20 @@ BOOL isCustomResolution(int resolutionSelected) { - (void) pointerVelocityModeDividerSliderMoved:(UISlider* )sender { [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" | %d%% | %d%% | ", (uint8_t)sender.value, 100-(uint8_t)sender.value]; + bool hasNoPresentedVC = self.presentedViewController == nil && self.view.window != nil; + if (@available(iOS 13.0, *)) if(!settingsViewJustExpanded && hasNoPresentedVC) { + [self showTouchVelocityPreviewWithDividerPercent:sender.value + velocityPercent:[self map_velocFactorDisplay_fromSliderValue:self.touchPointerVelocityFactorSlider.value]]; + } } - (void) touchPointerVelocityFactorSliderMoved:(UISlider* )sender { [self findDynamicLabelFromStack:(UIStackView*)sender.superview].text = [NSString stringWithFormat:@" %d%% ", (uint16_t)[self map_velocFactorDisplay_fromSliderValue:sender.value]]; // Update label display + bool hasNoPresentedVC = self.presentedViewController == nil && self.view.window != nil; + if (@available(iOS 13.0, *)) if(!settingsViewJustExpanded && hasNoPresentedVC) { + [self showTouchVelocityPreviewWithDividerPercent:self.pointerVelocityModeDividerSlider.value + velocityPercent:[self map_velocFactorDisplay_fromSliderValue:sender.value]]; + } } - (void) gyroSensitivitySliderMoved:(UISlider* )sender { diff --git a/VoidLink/ViewControllers/ToolboxViewController.swift b/VoidLink/ViewControllers/ToolboxViewController.swift index 5bd78eb1..c582bc37 100644 --- a/VoidLink/ViewControllers/ToolboxViewController.swift +++ b/VoidLink/ViewControllers/ToolboxViewController.swift @@ -93,6 +93,7 @@ import UIKit private var controllerNavigationExecuteDismissWindowOpen = false private var controllerNavigationExecuteDismissToken = 0 private var controllerNavigationExecuteSuppressNextRelease = false + private var pendingSpecialEntryToken = 0 private var isEditingMode = false { didSet { if !isEditingMode { @@ -643,10 +644,18 @@ import UIKit private func activateEntry(at indexPath: IndexPath, dismissAfterAction: Bool = true) { guard entries.indices.contains(indexPath.item) else { return } + let entryIdentifier = entries[indexPath.item].orderIdentifier + activateEntry(withIdentifier: entryIdentifier, fallbackIndexPath: indexPath, dismissAfterAction: dismissAfterAction) + } - switch entries[indexPath.item] { + private func activateEntry(withIdentifier identifier: String, fallbackIndexPath: IndexPath? = nil, dismissAfterAction: Bool = true) { + guard let item = entries.firstIndex(where: { $0.orderIdentifier == identifier }) else { return } + let indexPath = IndexPath(item: item, section: 0) + pendingSpecialEntryToken += 1 + + switch entries[item] { case .special(let id, _): - collectionView.deselectItem(at: indexPath, animated: false) + collectionView.deselectItem(at: fallbackIndexPath ?? indexPath, animated: false) if !isEditingMode { flashCell(at: indexPath) handleSpecialEntry(id: id) @@ -670,6 +679,7 @@ import UIKit sendKeyboardCommand(command) if dismissAfterAction && !viewPinned { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + guard self.presentingViewController != nil else { return } self.dismiss(animated: false) } } @@ -848,7 +858,11 @@ import UIKit } private func handleSpecialEntry(id: String) { + pendingSpecialEntryToken += 1 + let token = pendingSpecialEntryToken + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + guard self.pendingSpecialEntryToken == token else { return } self.dismiss(animated: false) switch id { @@ -1074,7 +1088,7 @@ extension ToolboxViewController: ControllerCollectionNavigationDelegate { controllerNavigationDeleteDoublePressToken += 1 let token = controllerNavigationDeleteDoublePressToken - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in guard let self, self.controllerNavigationDeleteDoublePressToken == token else { return @@ -1383,7 +1397,7 @@ private final class ToolboxCardCell: UICollectionViewCell, ControllerNavigationH } override var canBecomeFocused: Bool { - return true + return false } var controllerNavigationHighlightTargetView: UIView {