- feat: global controller navigation

- shorten initial host detection
This commit is contained in:
True Zhuanjia committed 2026-07-26 16:07:03 +08:00
1 parent 6460036661
commit 9a634da3ed
66 files changed
+6572 -1659

No files matched your search

+3 -3
View File
@@ -518,7 +518,7 @@ struct AbstractGamepadView: View {
applyTriggerSelection(widget)
applyDS4TouchSelection(widget)
print("selectedCmd =", AbstractGamepadView.selectedCmd)
// print("selectedCmd =", AbstractGamepadView.selectedCmd)
return true
}
@@ -733,8 +733,8 @@ struct AbstractGamepadView: View {
if liveSnapshot.pressedButtons.contains(.b) { result.insert(.b) }
if liveSnapshot.pressedButtons.contains(.x) { result.insert(.x) }
if liveSnapshot.pressedButtons.contains(.y) { result.insert(.y) }
if liveSnapshot.pressedButtons.contains(.menu) { result.insert(.start) }
if liveSnapshot.pressedButtons.contains(.back) { result.insert(.select) }
if liveSnapshot.pressedButtons.contains(.start) { result.insert(.start) }
if liveSnapshot.pressedButtons.contains(.select) { result.insert(.select) }
if liveSnapshot.pressedButtons.contains(.special) { result.insert(.home) }
if liveSnapshot.pressedButtons.contains(.leftShoulder) { result.insert(.leftShoulder) }
if liveSnapshot.pressedButtons.contains(.rightShoulder) { result.insert(.rightShoulder) }
+235 -24
View File
@@ -10,25 +10,42 @@ import SwiftUI
import Combine
import UIKit
public enum RadialMenuItem {
@objc public enum RadialMenuItem:Int {
case settings
case favoriteSettings
case allSettings
case addHost
case aboutView
case hostView
case gameProfiles
case disconnect
case mouse
case quitApp
case theme
case more
case navigationSettings
case exit
}
@objc public enum RadialMenuState:Int {
case main
case mouseModeEnabled
case disconnectAndQuit
case moreOptions
}
@available(iOS 13.0, *)
struct RadialMenuSector: Identifiable, Equatable {
let id = UUID()
let title: String
let subtitle: String
let systemImageName: String
let symbolName: String
let item: RadialMenuItem
init(title: String, subtitle: String, systemImageName: String, item: RadialMenuItem) {
init(title: String, subtitle: String, symbol: String, item: RadialMenuItem) {
self.title = title
self.subtitle = subtitle
self.systemImageName = systemImageName
self.symbolName = symbol
self.item = item
}
}
@@ -38,7 +55,7 @@ struct RadialMenuStyle: Equatable {
var ringColor = Color(white: 0.86)
var selectedRingColor = Color(red: 0.19, green: 0.72, blue: 0.96)
var ringStrokeColor = Color.white.opacity(0.72)
var centerFillColor = Color.white
var centerFillColor = Color(ThemeManager.hostViewBackgroundColor.withAlphaComponent(0.5))
var iconColor = Color(white: 0.23)
var selectedIconColor = Color(white: 0.16)
var titleColor = Color(white: 0.24)
@@ -46,35 +63,97 @@ struct RadialMenuStyle: Equatable {
var shadowColor = Color.black.opacity(0.18)
var ringWidthRatio: CGFloat = 0.41
var segmentGapWidth: CGFloat = 1
var centerIconScale: CGFloat = 0.16
var centerIconScale: CGFloat = 0.13
var segmentIconScale: CGFloat = 0.085
static func themed(for userInterfaceStyle: UIUserInterfaceStyle, accentColor: UIColor) -> RadialMenuStyle {
switch userInterfaceStyle {
case .dark:
return RadialMenuStyle(
ringColor: Color(UIColor(red: 44.0 / 255.0, green: 44.0 / 255.0, blue: 46.0 / 255.0, alpha: 0.88)),
selectedRingColor: Color(accentColor.withAlphaComponent(0.52)),
ringStrokeColor: Color.white.opacity(0.26),
// centerFillColor: Color(UIColor(red: 28.0 / 255.0, green: 28.0 / 255.0, blue: 30.0 / 255.0, alpha: 0.96)),
centerFillColor: Color(ThemeManager.hostViewBackgroundColor.withAlphaComponent(0.5)),
iconColor: Color(UIColor(white: 0.86, alpha: 1)),
selectedIconColor: Color.white,
titleColor: Color(UIColor(white: 0.92, alpha: 1)),
subtitleColor: Color(UIColor(white: 0.70, alpha: 1)),
shadowColor: Color(accentColor.withAlphaComponent(0)),
ringWidthRatio: 0.41,
segmentGapWidth: 1,
centerIconScale: 0.16,
segmentIconScale: 0.085
)
default:
return RadialMenuStyle(
selectedRingColor: Color(accentColor.withAlphaComponent(0.86))
)
}
}
}
@available(iOS 13.0, *)
enum RadialMenuSelectionChangeReason {
case began
case moved
case released
case unchanged
}
@available(iOS 13.0, *)
final class RadialMenuSelectionState: ObservableObject {
@Published private(set) var selectedIndex: Int?
@Published private(set) var hasReceivedJoystickInput = false
@Published private(set) var lastChangeReason: RadialMenuSelectionChangeReason = .unchanged
private var itemCount = 0
func updateSelection(xOffset: Float, yOffset: Float) {
func updateSelection(xOffset: CGFloat, yOffset: CGFloat) {
updateSelection(xOffset: xOffset, yOffset: yOffset, deadZone: 0.1)
}
func updateSelection(xOffset: Float, yOffset: Float, deadZone: Float) {
func updateSelection(xOffset: CGFloat, yOffset: CGFloat, deadZone: CGFloat) {
let x = max(min(xOffset, 1), -1)
let y = max(min(yOffset, 1), -1)
let distance = hypot(x, y)
if distance < deadZone {
guard !hasReceivedJoystickInput || selectedIndex != nil else {
lastChangeReason = .unchanged
return
}
hasReceivedJoystickInput = true
lastChangeReason = selectedIndex == nil ? .unchanged : .released
selectedIndex = nil
return
}
// Near center, tiny return-to-center offsets have unstable angles. Keep the
// last high-confidence sector until the stick fully enters the dead zone.
let selectionUpdateDeadZone = max(deadZone, 0.35)
guard distance >= selectionUpdateDeadZone else {
hasReceivedJoystickInput = true
lastChangeReason = .unchanged
return
}
let nextSelectedIndex = Self.selectedIndex(
xOffset: xOffset,
yOffset: yOffset,
xOffset: x,
yOffset: y,
itemCount: itemCount,
deadZone: deadZone
)
guard !hasReceivedJoystickInput || nextSelectedIndex != selectedIndex else {
lastChangeReason = .unchanged
return
}
let previousSelectedIndex = selectedIndex
hasReceivedJoystickInput = true
selectedIndex = nextSelectedIndex
lastChangeReason = Self.changeReason(from: previousSelectedIndex, to: nextSelectedIndex)
}
fileprivate func configureItemCount(_ itemCount: Int) {
@@ -84,7 +163,7 @@ final class RadialMenuSelectionState: ObservableObject {
}
}
static func selectedIndex(xOffset: Float, yOffset: Float, itemCount: Int, deadZone: Float = 0.1) -> Int? {
static func selectedIndex(xOffset: CGFloat, yOffset: CGFloat, itemCount: Int, deadZone: CGFloat = 0.1) -> Int? {
guard itemCount > 0 else { return nil }
let x = max(min(xOffset, 1), -1)
@@ -104,6 +183,19 @@ final class RadialMenuSelectionState: ObservableObject {
let remainder = value.truncatingRemainder(dividingBy: divisor)
return remainder >= 0 ? remainder : remainder + divisor
}
private static func changeReason(from previousIndex: Int?, to nextIndex: Int?) -> RadialMenuSelectionChangeReason {
switch (previousIndex, nextIndex) {
case (nil, .some):
return .began
case (.some(let previous), .some(let next)) where previous != next:
return .moved
case (.some, nil):
return .released
default:
return .unchanged
}
}
}
@available(iOS 13.0, *)
@@ -151,7 +243,7 @@ struct RadialMenuView: View {
self.joystickSelectionState.configureItemCount(sectors.count)
}
func updateSelection(xOffset: Float, yOffset: Float) {
func updateSelection(xOffset: CGFloat, yOffset: CGFloat) {
joystickSelectionState.configureItemCount(sectors.count)
joystickSelectionState.updateSelection(xOffset: xOffset, yOffset: yOffset)
}
@@ -192,9 +284,13 @@ struct RadialMenuView: View {
ForEach(sectors.indices, id: \.self) { index in
let item = sectors[index]
Image(systemName: item.systemImageName)
.font(.system(size: max(diameter * style.segmentIconScale, 18), weight: .medium))
.foregroundColor(index == effectiveSelectedIndex ? style.selectedIconColor : style.iconColor)
RadialMenuIconView(
symbolName: item.symbolName,
fallbackSystemName: "circle",
size: max(diameter * style.segmentIconScale, 18),
weight: .medium,
color: index == effectiveSelectedIndex ? style.selectedIconColor : style.iconColor
)
.scaleEffect(index == effectiveSelectedIndex ? 1.18 : 1)
.position(iconPosition(index: index, count: sectors.count, center: center, radius: (outerRadius + innerRadius) / 2))
.animation(.spring(response: 0.24, dampingFraction: 0.72), value: effectiveSelectedIndex)
@@ -203,7 +299,7 @@ struct RadialMenuView: View {
RadialMenuCenterView(item: selectedItem, style: style, diameter: diameter)
.frame(width: innerRadius * 1.72, height: innerRadius * 1.72)
.position(center)
.animation(.easeInOut(duration: 0.16), value: effectiveSelectedIndex)
.animation(.easeInOut(duration: selectedItem?.item == .gameProfiles ? 0 : 0.16), value: effectiveSelectedIndex)
}
.frame(width: proxy.size.width, height: proxy.size.height)
.contentShape(Circle())
@@ -266,18 +362,25 @@ private struct RadialMenuCenterView: View {
let diameter: CGFloat
var body: some View {
VStack(spacing: max(diameter * 0.018, 4)) {
Image(systemName: item?.systemImageName ?? "circle.grid.cross")
.font(.system(size: max(diameter * style.centerIconScale, 28), weight: .medium))
.foregroundColor(style.iconColor)
VStack(spacing: 0) {
RadialMenuIconView(
symbolName: item?.symbolName ?? "circle.grid.cross",
fallbackSystemName: "circle.grid.cross",
size: centerIconSize,
weight: .medium,
color: style.iconColor
)
.id(item?.id)
.transition(.opacity)
.frame(width: centerIconSize * 1.35, height: centerIconSize * 1.2)
.padding(.bottom, centerIconTitleSpacing)
Text(item?.title ?? "")
.font(.system(size: max(diameter * 0.047, 13), weight: .medium))
.foregroundColor(style.titleColor)
.lineLimit(1)
.minimumScaleFactor(0.62)
.padding(.bottom, item?.subtitle.isEmpty == false ? centerTextSpacing : 0)
if item?.subtitle.isEmpty == false {
Text(item?.subtitle ?? "")
@@ -290,6 +393,114 @@ private struct RadialMenuCenterView: View {
.multilineTextAlignment(.center)
.padding(.horizontal, diameter * 0.035)
}
private var centerIconSize: CGFloat {
max(diameter * style.centerIconScale, 28)
}
private var centerTextSpacing: CGFloat {
max(diameter * 0.018, 4)
}
private var centerIconTitleSpacing: CGFloat {
if #available(iOS 14.0, *) {
return centerTextSpacing
}
return max(diameter * 0.034, 8)
}
}
@available(iOS 13.0, *)
private struct RadialMenuIconView: View {
let symbolName: String
let fallbackSystemName: String
let size: CGFloat
let weight: Font.Weight
let color: Color
var body: some View {
Group {
if UIImage(systemName: symbolName) != nil {
Image(systemName: symbolName)
.font(.system(size: size, weight: weight))
} else if let assetImage = RadialMenuIconImageRenderer.templateImage(named: symbolName, pointSize: size) {
Image(uiImage: assetImage)
.renderingMode(.template)
} else {
Image(systemName: fallbackSystemName)
.font(.system(size: size, weight: weight))
}
}
.foregroundColor(color)
}
}
@available(iOS 13.0, *)
private enum RadialMenuIconImageRenderer {
private static let cache = NSCache<NSString, UIImage>()
private static let assetCanvasScale: CGFloat = 1.05
private static let assetVisualScale: CGFloat = 1
static func templateImage(named name: String, pointSize: CGFloat) -> UIImage? {
let scale = UIScreen.main.scale
let normalizedPointSize = max(ceil(pointSize * 2) / 2, 1)
let cacheKey = "\(name)-\(normalizedPointSize)-\(scale)-\(assetCanvasScale)-\(assetVisualScale)" as NSString
if let cachedImage = cache.object(forKey: cacheKey) {
return cachedImage
}
guard let sourceImage = UIImage(named: name) else {
return nil
}
let canvasSize = CGSize(
width: normalizedPointSize * assetCanvasScale,
height: normalizedPointSize * assetCanvasScale
)
let format = UIGraphicsImageRendererFormat()
format.scale = scale
format.opaque = false
let renderedImage = UIGraphicsImageRenderer(size: canvasSize, format: format).image { _ in
let drawBounds = scaledRect(
CGRect(origin: .zero, size: canvasSize),
by: assetVisualScale
)
sourceImage.draw(in: aspectFitRect(for: sourceImage.size, in: drawBounds))
}.withRenderingMode(.alwaysTemplate)
cache.setObject(renderedImage, forKey: cacheKey)
return renderedImage
}
private static func scaledRect(_ rect: CGRect, by scale: CGFloat) -> CGRect {
let width = rect.width * scale
let height = rect.height * scale
return CGRect(
x: rect.midX - width / 2,
y: rect.midY - height / 2,
width: width,
height: height
)
}
private static func aspectFitRect(for sourceSize: CGSize, in bounds: CGRect) -> CGRect {
guard sourceSize.width > 0, sourceSize.height > 0 else {
return bounds
}
let scale = min(bounds.width / sourceSize.width, bounds.height / sourceSize.height)
let width = sourceSize.width * scale
let height = sourceSize.height * scale
return CGRect(
x: bounds.midX - width / 2,
y: bounds.midY - height / 2,
width: width,
height: height
)
}
}
@available(iOS 13.0, *)
@@ -387,10 +598,10 @@ struct RadialMenuDemoView: View {
@SwiftUI.State private var isTouchSelectionEnabled = true
static let menuItems = [
RadialMenuSector(title: "Settings Menu".localized, subtitle: "", systemImageName: "sidebar.left", item: .settings),
RadialMenuSector(title: "Host View".localized, subtitle: "", systemImageName: PublicUtils.liquidGlassEnabled ? "macwindow.on.rectangle" : "tv", item: .hostView),
RadialMenuSector(title: "Game Profiles".localized, subtitle: "", systemImageName: "gamecontroller.circle", item: .gameProfiles),
RadialMenuSector(title: "Exit/Disconnect".localized, subtitle: "", systemImageName: "personalhotspot.slash", item: .gameProfiles),
RadialMenuSector(title: "Settings Menu".localized, subtitle: "", symbol: "sidebar.left", item: .settings),
RadialMenuSector(title: "Host View".localized, subtitle: "", symbol: PublicUtils.liquidGlassEnabled ? "macwindow.on.rectangle" : "tv", item: .hostView),
RadialMenuSector(title: "Game Profiles".localized, subtitle: "", symbol: "gamecontroller.circle", item: .gameProfiles),
RadialMenuSector(title: "Exit/Disconnect".localized, subtitle: "", symbol: "personalhotspot.slash", item: .gameProfiles),
]
private var visibleItems: [RadialMenuSector] {
+1 -1
View File
@@ -1568,7 +1568,7 @@ struct KeyView: View {
.onEnded { _ in
isPressed = false
action?()
print("cmdString \(String(describing: cmdString)) code \(String(describing: keyCode))")
// print("cmdString \(String(describing: cmdString)) code \(String(describing: keyCode))")
}
)
}
+1 -1
View File
@@ -777,7 +777,7 @@ struct WidgetPickerView: View {
FunctionalButtonOption(
localizationKey: "=disableTilt",
cmd: "DISABLETILT",
tip: LocalizationHelper.localizedString(forKey: "Disable tilt for apple pencil"),
tip: "Disable tilt for apple pencil".localized,
allowsKeyboardCombination: false,
allowsGamepadCombination: false,
allowsSkillCombo: false,