VMToolbarView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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. struct VMToolbarView: View {
  18. @AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = true
  19. @AppStorage("ToolbarLocation") private var location: ToolbarLocation = .topRight
  20. @State private var shake: Bool = true
  21. @State private var isMoving: Bool = false
  22. @State private var isIdle: Bool = false
  23. @State private var dragPosition: CGPoint = .zero
  24. @State private var shortIdleTask: DispatchWorkItem?
  25. @Environment(\.horizontalSizeClass) private var horizontalSizeClass
  26. @Environment(\.verticalSizeClass) private var verticalSizeClass
  27. @EnvironmentObject private var session: VMSessionState
  28. @StateObject private var longIdleTimeout = LongIdleTimeout()
  29. @Binding var state: VMWindowState
  30. private var spacing: CGFloat {
  31. let direction: CGFloat
  32. let distance: CGFloat
  33. if location == .topLeft || location == .bottomLeft {
  34. direction = -1
  35. } else {
  36. direction = 1
  37. }
  38. if horizontalSizeClass == .compact || verticalSizeClass == .compact {
  39. distance = 40
  40. } else {
  41. distance = 56
  42. }
  43. return direction * distance
  44. }
  45. private var nameOfHideIcon: String {
  46. if location == .topLeft || location == .bottomLeft {
  47. return "chevron.right"
  48. } else {
  49. return "chevron.left"
  50. }
  51. }
  52. private var nameOfShowIcon: String {
  53. if location == .topLeft || location == .bottomLeft {
  54. return "chevron.left"
  55. } else {
  56. return "chevron.right"
  57. }
  58. }
  59. private var toolbarToggleOpacity: Double {
  60. if state.device != nil && !state.isBusy && state.isRunning && isCollapsed && !isMoving {
  61. if !longIdleTimeout.isUserInteracting {
  62. return 0
  63. } else if isIdle {
  64. return 0.4
  65. } else {
  66. return 1
  67. }
  68. } else {
  69. return 1
  70. }
  71. }
  72. var body: some View {
  73. GeometryReader { geometry in
  74. Group {
  75. Button {
  76. if session.vm.state == .started {
  77. state.alert = .powerDown
  78. } else {
  79. state.alert = .terminateApp
  80. }
  81. } label: {
  82. Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
  83. }.offset(offset(for: 8))
  84. Button {
  85. session.pauseResume()
  86. } label: {
  87. Label(state.isRunning ? "Pause" : "Play", systemImage: state.isRunning ? "pause" : "play")
  88. }.offset(offset(for: 7))
  89. Button {
  90. state.alert = .restart
  91. } label: {
  92. Label("Restart", systemImage: "restart")
  93. }.offset(offset(for: 6))
  94. Button {
  95. if case .serial(_, _) = state.device {
  96. let template = session.qemuConfig.serials[state.device!.configIndex].terminal?.resizeCommand
  97. state.toggleDisplayResize(command: template)
  98. } else {
  99. state.toggleDisplayResize()
  100. }
  101. } label: {
  102. Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
  103. }.offset(offset(for: 5))
  104. #if WITH_USB
  105. if session.vm.hasUsbRedirection {
  106. VMToolbarUSBMenuView()
  107. .offset(offset(for: 4))
  108. }
  109. #endif
  110. VMToolbarDriveMenuView(config: session.qemuConfig)
  111. .offset(offset(for: 3))
  112. VMToolbarDisplayMenuView(state: $state)
  113. .offset(offset(for: 2))
  114. Button {
  115. state.isKeyboardRequested = !state.isKeyboardShown
  116. } label: {
  117. Label("Keyboard", systemImage: "keyboard")
  118. }.offset(offset(for: 1))
  119. }.buttonStyle(.toolbar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass))
  120. .menuStyle(.toolbar)
  121. .disabled(state.isBusy)
  122. .opacity(isCollapsed ? 0 : 1)
  123. .position(position(for: geometry))
  124. .transition(.slide)
  125. .animation(.default)
  126. Button {
  127. resetIdle()
  128. longIdleTimeout.assertUserInteraction()
  129. withOptionalAnimation {
  130. isCollapsed.toggle()
  131. }
  132. } label: {
  133. Label("Hide", systemImage: isCollapsed ? nameOfHideIcon : nameOfShowIcon)
  134. }.buttonStyle(.toolbar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass))
  135. .opacity(toolbarToggleOpacity)
  136. .modifier(Shake(shake: shake))
  137. .position(position(for: geometry))
  138. .highPriorityGesture(
  139. DragGesture()
  140. .onChanged { value in
  141. withOptionalAnimation {
  142. isCollapsed = true
  143. }
  144. isMoving = true
  145. dragPosition = value.location
  146. }
  147. .onEnded { value in
  148. withOptionalAnimation {
  149. location = closestLocation(to: value.location, for: geometry)
  150. isMoving = false
  151. dragPosition = position(for: geometry)
  152. }
  153. resetIdle()
  154. longIdleTimeout.assertUserInteraction()
  155. }
  156. )
  157. .onAppear {
  158. resetIdle()
  159. longIdleTimeout.assertUserInteraction()
  160. if isCollapsed {
  161. withOptionalAnimation(.easeInOut(duration: 1)) {
  162. shake.toggle()
  163. }
  164. }
  165. }
  166. .onChange(of: state.isUserInteracting) { newValue in
  167. longIdleTimeout.assertUserInteraction()
  168. session.activeWindow = state.id
  169. }
  170. }
  171. }
  172. private func withOptionalAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result {
  173. if UIAccessibility.isReduceMotionEnabled {
  174. return try body()
  175. } else {
  176. return try withAnimation(animation, body)
  177. }
  178. }
  179. private func position(for geometry: GeometryProxy) -> CGPoint {
  180. let yoffset: CGFloat = 48
  181. var xoffset: CGFloat = 48
  182. guard !isMoving else {
  183. return dragPosition
  184. }
  185. if session.vm.hasUsbRedirection && !isCollapsed {
  186. xoffset -= 12
  187. }
  188. switch location {
  189. case .topRight:
  190. return CGPoint(x: geometry.size.width - xoffset, y: yoffset)
  191. case .bottomRight:
  192. return CGPoint(x: geometry.size.width - xoffset, y: geometry.size.height - yoffset)
  193. case .topLeft:
  194. return CGPoint(x: xoffset, y: yoffset)
  195. case .bottomLeft:
  196. return CGPoint(x: xoffset, y: geometry.size.height - yoffset)
  197. }
  198. }
  199. private func closestLocation(to point: CGPoint, for geometry: GeometryProxy) -> ToolbarLocation {
  200. if point.x < geometry.size.width/2 && point.y < geometry.size.height/2 {
  201. return .topLeft
  202. } else if point.x < geometry.size.width/2 && point.y > geometry.size.height/2 {
  203. return .bottomLeft
  204. } else if point.x > geometry.size.width/2 && point.y > geometry.size.height/2 {
  205. return .bottomRight
  206. } else {
  207. return .topRight
  208. }
  209. }
  210. private func offset(for index: Int) -> CGSize {
  211. var sub = 0
  212. if !session.vm.hasUsbRedirection && index >= 4 {
  213. sub = 1
  214. }
  215. let x = isCollapsed ? 0 : -CGFloat(index-sub)*spacing
  216. return CGSize(width: x, height: 0)
  217. }
  218. private func resetIdle() {
  219. if let task = shortIdleTask {
  220. task.cancel()
  221. }
  222. self.isIdle = false
  223. shortIdleTask = DispatchWorkItem {
  224. self.shortIdleTask = nil
  225. withOptionalAnimation {
  226. self.isIdle = true
  227. }
  228. }
  229. DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: shortIdleTask!)
  230. }
  231. }
  232. enum ToolbarLocation: Int {
  233. case topRight
  234. case bottomRight
  235. case topLeft
  236. case bottomLeft
  237. }
  238. protocol ToolbarButtonBaseStyle<Label, Content> {
  239. associatedtype Label: View
  240. associatedtype Content: View
  241. var horizontalSizeClass: UserInterfaceSizeClass? { get }
  242. var verticalSizeClass: UserInterfaceSizeClass? { get }
  243. func makeBodyBase(label: Label, isPressed: Bool) -> Content
  244. }
  245. extension ToolbarButtonBaseStyle {
  246. private var size: CGFloat {
  247. (horizontalSizeClass == .compact || verticalSizeClass == .compact) ? 32 : 48
  248. }
  249. func makeBodyBase(label: Label, isPressed: Bool) -> some View {
  250. ZStack {
  251. Circle()
  252. .foregroundColor(.gray)
  253. .opacity(isPressed ? 0.8 : 0.7)
  254. .blur(radius: 0.1)
  255. label
  256. .labelStyle(.iconOnly)
  257. .foregroundColor(isPressed ? .secondary : .white)
  258. .opacity(0.75)
  259. }.frame(width: size, height: size)
  260. .mask(Circle().frame(width: size-2, height: size-2))
  261. .scaleEffect(isPressed ? 1.2 : 1)
  262. .hoverEffect(.lift)
  263. }
  264. }
  265. struct ToolbarButtonStyle: ButtonStyle, ToolbarButtonBaseStyle {
  266. typealias Label = Configuration.Label
  267. @Environment(\.horizontalSizeClass) private var horizontalSizeClassEnvironment
  268. @Environment(\.verticalSizeClass) private var verticalSizeClassEnvironment
  269. var horizontalSizeClass: UserInterfaceSizeClass?
  270. var verticalSizeClass: UserInterfaceSizeClass?
  271. init(horizontalSizeClass: UserInterfaceSizeClass? = nil, verticalSizeClass: UserInterfaceSizeClass? = nil) {
  272. if horizontalSizeClass != nil {
  273. self.horizontalSizeClass = horizontalSizeClass
  274. } else {
  275. self.horizontalSizeClass = horizontalSizeClassEnvironment
  276. }
  277. if verticalSizeClass != nil {
  278. self.verticalSizeClass = verticalSizeClass
  279. } else {
  280. self.verticalSizeClass = verticalSizeClassEnvironment
  281. }
  282. }
  283. func makeBody(configuration: Configuration) -> some View {
  284. return makeBodyBase(label: configuration.label, isPressed: configuration.isPressed)
  285. }
  286. }
  287. struct ToolbarMenuStyle: MenuStyle, ToolbarButtonBaseStyle {
  288. typealias Label = Menu<Configuration.Label, Configuration.Content>
  289. @Environment(\.horizontalSizeClass) internal var horizontalSizeClass
  290. @Environment(\.verticalSizeClass) internal var verticalSizeClass
  291. func makeBody(configuration: Configuration) -> some View {
  292. return makeBodyBase(label: Menu(configuration), isPressed: false)
  293. }
  294. }
  295. // https://www.objc.io/blog/2019/10/01/swiftui-shake-animation/
  296. struct Shake: GeometryEffect {
  297. var amount: CGFloat = 8
  298. var shakesPerUnit = 3
  299. var animatableData: CGFloat
  300. init(shake: Bool) {
  301. animatableData = shake ? 1.0 : 0.0
  302. }
  303. func effectValue(size: CGSize) -> ProjectionTransform {
  304. ProjectionTransform(CGAffineTransform(translationX:
  305. amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
  306. y: 0))
  307. }
  308. }
  309. extension ButtonStyle where Self == ToolbarButtonStyle {
  310. static var toolbar: ToolbarButtonStyle {
  311. ToolbarButtonStyle()
  312. }
  313. // this is needed to workaround a SwiftUI bug on < iOS 15
  314. static func toolbar(horizontalSizeClass: UserInterfaceSizeClass?, verticalSizeClass: UserInterfaceSizeClass?) -> ToolbarButtonStyle {
  315. ToolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
  316. }
  317. }
  318. extension MenuStyle where Self == ToolbarMenuStyle {
  319. static var toolbar: ToolbarMenuStyle {
  320. ToolbarMenuStyle()
  321. }
  322. }
  323. @MainActor private class LongIdleTimeout: ObservableObject {
  324. private var longIdleTask: DispatchWorkItem?
  325. @Published var isUserInteracting: Bool = true
  326. private func setIsUserInteracting(_ value: Bool) {
  327. if !UIAccessibility.isReduceMotionEnabled {
  328. withAnimation {
  329. self.isUserInteracting = value
  330. }
  331. } else {
  332. self.isUserInteracting = value
  333. }
  334. }
  335. func assertUserInteraction() {
  336. if let task = longIdleTask {
  337. task.cancel()
  338. }
  339. setIsUserInteracting(true)
  340. longIdleTask = DispatchWorkItem {
  341. self.longIdleTask = nil
  342. self.setIsUserInteracting(false)
  343. }
  344. DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: longIdleTask!)
  345. }
  346. }