VMToolbarView.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. //
  2. // Copyright © 2021 osy. All rights reserved.
  3. //
  4. // Licensed under the Apache License, Version 2.0 (the "License");
  5. // you may not use this file except in compliance with the License.
  6. // You may obtain a copy of the License at
  7. //
  8. // http://www.apache.org/licenses/LICENSE-2.0
  9. //
  10. // Unless required by applicable law or agreed to in writing, software
  11. // distributed under the License is distributed on an "AS IS" BASIS,
  12. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. // See the License for the specific language governing permissions and
  14. // limitations under the License.
  15. //
  16. import SwiftUI
  17. import TipKit
  18. struct VMToolbarView: View {
  19. @AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = false
  20. @AppStorage("ToolbarLocation") private var location: ToolbarLocation = .topRight
  21. @State private var shake: Bool = true
  22. @State private var isMoving: Bool = false
  23. @State private var isIdle: Bool = false
  24. @State private var dragOffset: CGSize = .zero
  25. @State private var shortIdleTask: DispatchWorkItem?
  26. @State private var isKeyShortcutsShown: Bool = false
  27. @Environment(\.horizontalSizeClass) private var horizontalSizeClass
  28. @Environment(\.verticalSizeClass) private var verticalSizeClass
  29. @EnvironmentObject private var session: VMSessionState
  30. @StateObject private var longIdleTimeout = LongIdleTimeout()
  31. @Binding var state: VMWindowState
  32. @Namespace private var namespace
  33. private var spacing: CGFloat {
  34. let add: CGFloat
  35. if #available(iOS 26, *) {
  36. add = 0
  37. } else {
  38. add = 8
  39. }
  40. if horizontalSizeClass == .compact || verticalSizeClass == .compact {
  41. return add + 0
  42. } else {
  43. return add + 8
  44. }
  45. }
  46. private var nameOfHideIcon: String {
  47. if location == .topLeft || location == .bottomLeft {
  48. return "chevron.right"
  49. } else {
  50. return "chevron.left"
  51. }
  52. }
  53. private var nameOfShowIcon: String {
  54. if location == .topLeft || location == .bottomLeft {
  55. return "chevron.left"
  56. } else {
  57. return "chevron.right"
  58. }
  59. }
  60. private var toolbarToggleOpacity: Double {
  61. if state.device != nil && !state.isBusy && state.isRunning && isCollapsed && !isMoving {
  62. if !longIdleTimeout.isUserInteracting {
  63. return 0
  64. } else if isIdle {
  65. return 0.4
  66. } else {
  67. return 1
  68. }
  69. } else {
  70. return 1
  71. }
  72. }
  73. var body: some View {
  74. if #available(iOS 26, *) {
  75. GlassEffectContainer(spacing: spacing) {
  76. toolbarBody
  77. }
  78. } else {
  79. toolbarBody
  80. }
  81. }
  82. @ViewBuilder
  83. var toolbarBody: some View {
  84. toolbarContainer { geometry in
  85. if !isCollapsed {
  86. Group {
  87. Button {
  88. if state.isRunning {
  89. state.alert = .powerDown
  90. } else {
  91. state.alert = .terminateApp
  92. }
  93. } label: {
  94. if state.isRunning {
  95. Label("Power Off", systemImage: "power")
  96. } else {
  97. Label("Force Kill", systemImage: "xmark")
  98. }
  99. }.animationUniqueID("power", in: namespace)
  100. Button {
  101. session.pauseResume()
  102. } label: {
  103. Label(state.isRunning ? "Pause" : "Play", systemImage: state.isRunning ? "pause" : "play")
  104. }.animationUniqueID("pause", in: namespace)
  105. Button {
  106. state.alert = .restart
  107. } label: {
  108. Label("Restart", systemImage: "restart")
  109. }.animationUniqueID("restart", in: namespace)
  110. Button {
  111. if case .serial(_, _) = state.device {
  112. let template = session.qemuConfig.serials[state.device!.configIndex].terminal?.resizeCommand
  113. state.toggleDisplayResize(command: template)
  114. } else {
  115. state.toggleDisplayResize()
  116. }
  117. } label: {
  118. Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
  119. }.animationUniqueID("resize", in: namespace)
  120. #if WITH_USB
  121. if session.vm.hasUsbRedirection {
  122. VMToolbarUSBMenuView()
  123. .animationUniqueID("usb", in: namespace)
  124. }
  125. #endif
  126. VMToolbarDriveMenuView(config: session.qemuConfig)
  127. .animationUniqueID("drive", in: namespace)
  128. VMToolbarDisplayMenuView(state: $state)
  129. .animationUniqueID("display", in: namespace)
  130. Button {
  131. // ignore if we are showing shortcuts
  132. guard !isKeyShortcutsShown else {
  133. return
  134. }
  135. state.isKeyboardRequested = !state.isKeyboardShown
  136. } label: {
  137. Label("Keyboard", systemImage: "keyboard")
  138. }.animationUniqueID("keyboard", in: namespace)
  139. #if !WITH_REMOTE
  140. .simultaneousGesture(
  141. LongPressGesture().onEnded { _ in
  142. isKeyShortcutsShown.toggle()
  143. }
  144. )
  145. .sheet(isPresented: $isKeyShortcutsShown) {
  146. VMKeyboardShortcutsView { keys in
  147. session.sendKeys(keys: keys)
  148. }
  149. }
  150. #endif
  151. }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
  152. .disabled(state.isBusy)
  153. }
  154. Button {
  155. resetIdle()
  156. longIdleTimeout.assertUserInteraction()
  157. withOptionalAnimation {
  158. isCollapsed.toggle()
  159. }
  160. } label: {
  161. Label("Hide", systemImage: isCollapsed ? nameOfHideIcon : nameOfShowIcon)
  162. }.toolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
  163. .animationUniqueID("hide", in: namespace)
  164. .modifier(HideToolbarTipModifier(isCollapsed: $isCollapsed))
  165. .opacity(toolbarToggleOpacity)
  166. .modifier(Shake(shake: shake))
  167. .offset(dragOffset)
  168. .highPriorityGesture(
  169. DragGesture(coordinateSpace: .named("Window"))
  170. .onChanged { value in
  171. withOptionalAnimation {
  172. isCollapsed = true
  173. isMoving = true
  174. dragOffset = value.translation
  175. }
  176. }
  177. .onEnded { value in
  178. withOptionalAnimation {
  179. location = closestLocation(to: value.location, for: geometry)
  180. isMoving = false
  181. dragOffset = .zero
  182. }
  183. resetIdle()
  184. longIdleTimeout.assertUserInteraction()
  185. }
  186. )
  187. .onAppear {
  188. resetIdle()
  189. longIdleTimeout.assertUserInteraction()
  190. if isCollapsed {
  191. withOptionalAnimation(.easeInOut(duration: 1)) {
  192. shake.toggle()
  193. }
  194. }
  195. }
  196. .onChange(of: state.isUserInteracting) { newValue in
  197. longIdleTimeout.assertUserInteraction()
  198. session.activeWindow = state.id
  199. }
  200. }
  201. }
  202. @ViewBuilder
  203. private func toolbarContainer<Content: View>(@ViewBuilder body: @escaping (GeometryProxy) -> Content) -> some View {
  204. GeometryReader { geometry in
  205. switch location {
  206. case .topRight:
  207. VStack(alignment: .trailing) {
  208. HStack(alignment: .top, spacing: spacing) {
  209. Spacer()
  210. body(geometry)
  211. }.padding(.trailing)
  212. Spacer()
  213. }.padding(.top)
  214. case .bottomRight:
  215. VStack(alignment: .trailing) {
  216. Spacer()
  217. HStack(alignment: .bottom, spacing: spacing) {
  218. Spacer()
  219. body(geometry)
  220. }.padding(.trailing)
  221. }.padding(.bottom)
  222. case .topLeft:
  223. VStack(alignment: .leading) {
  224. HStack(alignment: .top, spacing: spacing) {
  225. body(geometry)
  226. Spacer()
  227. }.padding(.leading)
  228. Spacer()
  229. }.padding(.top)
  230. case .bottomLeft:
  231. VStack(alignment: .leading) {
  232. Spacer()
  233. HStack(alignment: .bottom, spacing: spacing) {
  234. body(geometry)
  235. Spacer()
  236. }.padding(.leading)
  237. }.padding(.bottom)
  238. }
  239. }.coordinateSpace(name: "Window")
  240. }
  241. private func withOptionalAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result {
  242. if UIAccessibility.isReduceMotionEnabled {
  243. return try body()
  244. } else {
  245. return try withAnimation(animation, body)
  246. }
  247. }
  248. private func closestLocation(to point: CGPoint, for geometry: GeometryProxy) -> ToolbarLocation {
  249. if point.x < geometry.size.width/2 && point.y < geometry.size.height/2 {
  250. return .topLeft
  251. } else if point.x < geometry.size.width/2 && point.y > geometry.size.height/2 {
  252. return .bottomLeft
  253. } else if point.x > geometry.size.width/2 && point.y > geometry.size.height/2 {
  254. return .bottomRight
  255. } else {
  256. return .topRight
  257. }
  258. }
  259. private func resetIdle() {
  260. if let task = shortIdleTask {
  261. task.cancel()
  262. }
  263. self.isIdle = false
  264. shortIdleTask = DispatchWorkItem {
  265. self.shortIdleTask = nil
  266. withOptionalAnimation {
  267. self.isIdle = true
  268. }
  269. }
  270. DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: shortIdleTask!)
  271. }
  272. }
  273. enum ToolbarLocation: Int {
  274. case topRight
  275. case bottomRight
  276. case topLeft
  277. case bottomLeft
  278. }
  279. protocol ToolbarButtonBaseStyle<Label, Content> {
  280. associatedtype Label: View
  281. associatedtype Content: View
  282. var horizontalSizeClass: UserInterfaceSizeClass? { get }
  283. var verticalSizeClass: UserInterfaceSizeClass? { get }
  284. func makeBodyBase(label: Label, isPressed: Bool) -> Content
  285. }
  286. extension ToolbarButtonBaseStyle {
  287. private var size: CGFloat {
  288. (horizontalSizeClass == .compact || verticalSizeClass == .compact) ? 32 : 48
  289. }
  290. func makeBodyBase(label: Label, isPressed: Bool) -> some View {
  291. ZStack {
  292. Circle()
  293. .foregroundColor(.gray)
  294. .opacity(isPressed ? 0.8 : 0.7)
  295. .blur(radius: 0.1)
  296. label
  297. .labelStyle(.iconOnly)
  298. .foregroundColor(isPressed ? .secondary : .white)
  299. .opacity(0.75)
  300. }.frame(width: size, height: size)
  301. .mask(Circle().frame(width: size-2, height: size-2))
  302. .scaleEffect(isPressed ? 1.2 : 1)
  303. .hoverEffect(.lift)
  304. }
  305. }
  306. struct ToolbarButtonStyle: ButtonStyle, ToolbarButtonBaseStyle {
  307. typealias Label = Configuration.Label
  308. @Environment(\.horizontalSizeClass) private var horizontalSizeClassEnvironment
  309. @Environment(\.verticalSizeClass) private var verticalSizeClassEnvironment
  310. var horizontalSizeClass: UserInterfaceSizeClass?
  311. var verticalSizeClass: UserInterfaceSizeClass?
  312. init(horizontalSizeClass: UserInterfaceSizeClass? = nil, verticalSizeClass: UserInterfaceSizeClass? = nil) {
  313. if horizontalSizeClass != nil {
  314. self.horizontalSizeClass = horizontalSizeClass
  315. } else {
  316. self.horizontalSizeClass = horizontalSizeClassEnvironment
  317. }
  318. if verticalSizeClass != nil {
  319. self.verticalSizeClass = verticalSizeClass
  320. } else {
  321. self.verticalSizeClass = verticalSizeClassEnvironment
  322. }
  323. }
  324. func makeBody(configuration: Configuration) -> some View {
  325. return makeBodyBase(label: configuration.label, isPressed: configuration.isPressed)
  326. }
  327. }
  328. struct ToolbarMenuStyle: MenuStyle, ToolbarButtonBaseStyle {
  329. typealias Label = Menu<Configuration.Label, Configuration.Content>
  330. @Environment(\.horizontalSizeClass) internal var horizontalSizeClass
  331. @Environment(\.verticalSizeClass) internal var verticalSizeClass
  332. func makeBody(configuration: Configuration) -> some View {
  333. return makeBodyBase(label: Menu(configuration), isPressed: false)
  334. }
  335. }
  336. private extension View {
  337. @ViewBuilder
  338. func toolbarButtonStyle(horizontalSizeClass: UserInterfaceSizeClass? = nil, verticalSizeClass: UserInterfaceSizeClass? = nil) -> some View {
  339. if #available(iOS 26, *) {
  340. self
  341. .menuStyle(.button)
  342. .buttonStyle(.glass)
  343. .buttonBorderShape(.circle)
  344. .labelStyle(.iconOnly)
  345. .foregroundStyle(.primary)
  346. .controlSize(forHorizontalSizeClass: horizontalSizeClass)
  347. } else {
  348. self
  349. .buttonStyle(.toolbar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass))
  350. .menuStyle(.toolbar)
  351. }
  352. }
  353. @ViewBuilder
  354. func animationUniqueID(_ id: (some Hashable & Sendable)?, in namespace: Namespace.ID) -> some View {
  355. if #available(iOS 26, *) {
  356. self
  357. .glassEffectID(id, in: namespace)
  358. .matchedGeometryEffect(id: id, in: namespace)
  359. } else {
  360. self
  361. .matchedGeometryEffect(id: id, in: namespace)
  362. }
  363. }
  364. @available(iOS 15, *)
  365. @ViewBuilder
  366. func controlSize(forHorizontalSizeClass horizontalSizeClass: UserInterfaceSizeClass?) -> some View {
  367. if horizontalSizeClass == .regular {
  368. self.controlSize(.large)
  369. } else {
  370. self
  371. }
  372. }
  373. }
  374. // https://www.objc.io/blog/2019/10/01/swiftui-shake-animation/
  375. struct Shake: GeometryEffect {
  376. var amount: CGFloat = 8
  377. var shakesPerUnit = 3
  378. var animatableData: CGFloat
  379. init(shake: Bool) {
  380. animatableData = shake ? 1.0 : 0.0
  381. }
  382. func effectValue(size: CGSize) -> ProjectionTransform {
  383. ProjectionTransform(CGAffineTransform(translationX:
  384. amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
  385. y: 0))
  386. }
  387. }
  388. extension ButtonStyle where Self == ToolbarButtonStyle {
  389. static var toolbar: ToolbarButtonStyle {
  390. ToolbarButtonStyle()
  391. }
  392. // this is needed to workaround a SwiftUI bug on < iOS 15
  393. static func toolbar(horizontalSizeClass: UserInterfaceSizeClass?, verticalSizeClass: UserInterfaceSizeClass?) -> ToolbarButtonStyle {
  394. ToolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
  395. }
  396. }
  397. extension MenuStyle where Self == ToolbarMenuStyle {
  398. static var toolbar: ToolbarMenuStyle {
  399. ToolbarMenuStyle()
  400. }
  401. }
  402. @MainActor private class LongIdleTimeout: ObservableObject {
  403. private var longIdleTask: DispatchWorkItem?
  404. @Published var isUserInteracting: Bool = true
  405. private func setIsUserInteracting(_ value: Bool) {
  406. if !UIAccessibility.isReduceMotionEnabled {
  407. withAnimation {
  408. self.isUserInteracting = value
  409. }
  410. } else {
  411. self.isUserInteracting = value
  412. }
  413. }
  414. func assertUserInteraction() {
  415. if let task = longIdleTask {
  416. task.cancel()
  417. }
  418. setIsUserInteracting(true)
  419. longIdleTask = DispatchWorkItem {
  420. self.longIdleTask = nil
  421. self.setIsUserInteracting(false)
  422. }
  423. DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: longIdleTask!)
  424. }
  425. }
  426. private struct HideToolbarTipModifier: ViewModifier {
  427. @Binding var isCollapsed: Bool
  428. private let _hideToolbarTip: Any?
  429. @available(iOS 17, *)
  430. private var hideToolbarTip: UTMTipHideToolbar {
  431. _hideToolbarTip as! UTMTipHideToolbar
  432. }
  433. init(isCollapsed: Binding<Bool>) {
  434. _isCollapsed = isCollapsed
  435. if #available(iOS 17, *) {
  436. _hideToolbarTip = UTMTipHideToolbar()
  437. } else {
  438. _hideToolbarTip = nil
  439. }
  440. }
  441. @ViewBuilder
  442. func body(content: Content) -> some View {
  443. if #available(iOS 17, *) {
  444. content
  445. .popoverTip(hideToolbarTip, arrowEdge: .top)
  446. .onAppear {
  447. UTMTipHideToolbar.didHideToolbar = isCollapsed
  448. }
  449. } else {
  450. content
  451. }
  452. }
  453. }