123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- //
- // Copyright © 2021 osy. All rights reserved.
- //
- // Licensed under the Apache License, Version 2.0 (the "License");
- // you may not use this file except in compliance with the License.
- // You may obtain a copy of the License at
- //
- // http://www.apache.org/licenses/LICENSE-2.0
- //
- // Unless required by applicable law or agreed to in writing, software
- // distributed under the License is distributed on an "AS IS" BASIS,
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- // See the License for the specific language governing permissions and
- // limitations under the License.
- //
- import SwiftUI
- struct VMToolbarView: View {
- @AppStorage("ToolbarIsCollapsed") private var isCollapsed: Bool = true
- @AppStorage("ToolbarLocation") private var location: ToolbarLocation = .topRight
- @State private var shake: Bool = true
- @State private var isMoving: Bool = false
- @State private var isIdle: Bool = false
- @State private var dragPosition: CGPoint = .zero
- @State private var shortIdleTask: DispatchWorkItem?
-
- @Environment(\.horizontalSizeClass) private var horizontalSizeClass
- @Environment(\.verticalSizeClass) private var verticalSizeClass
- @EnvironmentObject private var session: VMSessionState
- @StateObject private var longIdleTimeout = LongIdleTimeout()
-
- @Binding var state: VMWindowState
-
- private var spacing: CGFloat {
- let direction: CGFloat
- let distance: CGFloat
- if location == .topLeft || location == .bottomLeft {
- direction = -1
- } else {
- direction = 1
- }
- if horizontalSizeClass == .compact || verticalSizeClass == .compact {
- distance = 40
- } else {
- distance = 56
- }
- return direction * distance
- }
-
- private var nameOfHideIcon: String {
- if location == .topLeft || location == .bottomLeft {
- return "chevron.right"
- } else {
- return "chevron.left"
- }
- }
-
- private var nameOfShowIcon: String {
- if location == .topLeft || location == .bottomLeft {
- return "chevron.left"
- } else {
- return "chevron.right"
- }
- }
-
- private var toolbarToggleOpacity: Double {
- if state.device != nil && !state.isBusy && state.isRunning && isCollapsed && !isMoving {
- if !longIdleTimeout.isUserInteracting {
- return 0
- } else if isIdle {
- return 0.4
- } else {
- return 1
- }
- } else {
- return 1
- }
- }
-
- var body: some View {
- GeometryReader { geometry in
- Group {
- Button {
- if session.vm.state == .started {
- state.alert = .powerDown
- } else {
- state.alert = .terminateApp
- }
- } label: {
- Label(state.isRunning ? "Power Off" : "Quit", systemImage: state.isRunning ? "power" : "xmark")
- }.offset(offset(for: 8))
- Button {
- session.pauseResume()
- } label: {
- Label(state.isRunning ? "Pause" : "Play", systemImage: state.isRunning ? "pause" : "play")
- }.offset(offset(for: 7))
- Button {
- state.alert = .restart
- } label: {
- Label("Restart", systemImage: "restart")
- }.offset(offset(for: 6))
- Button {
- if case .serial(_, _) = state.device {
- let template = session.qemuConfig.serials[state.device!.configIndex].terminal?.resizeCommand
- state.toggleDisplayResize(command: template)
- } else {
- state.toggleDisplayResize()
- }
- } label: {
- Label("Zoom", systemImage: state.isViewportChanged ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
- }.offset(offset(for: 5))
- #if WITH_USB
- if session.vm.hasUsbRedirection {
- VMToolbarUSBMenuView()
- .offset(offset(for: 4))
- }
- #endif
- VMToolbarDriveMenuView(config: session.qemuConfig)
- .offset(offset(for: 3))
- VMToolbarDisplayMenuView(state: $state)
- .offset(offset(for: 2))
- Button {
- state.isKeyboardRequested = !state.isKeyboardShown
- } label: {
- Label("Keyboard", systemImage: "keyboard")
- }.offset(offset(for: 1))
- }.buttonStyle(.toolbar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass))
- .menuStyle(.toolbar)
- .disabled(state.isBusy)
- .opacity(isCollapsed ? 0 : 1)
- .position(position(for: geometry))
- .transition(.slide)
- .animation(.default)
- Button {
- resetIdle()
- longIdleTimeout.assertUserInteraction()
- withOptionalAnimation {
- isCollapsed.toggle()
- }
- } label: {
- Label("Hide", systemImage: isCollapsed ? nameOfHideIcon : nameOfShowIcon)
- }.buttonStyle(.toolbar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass))
- .opacity(toolbarToggleOpacity)
- .modifier(Shake(shake: shake))
- .position(position(for: geometry))
- .highPriorityGesture(
- DragGesture()
- .onChanged { value in
- withOptionalAnimation {
- isCollapsed = true
- }
- isMoving = true
- dragPosition = value.location
- }
- .onEnded { value in
- withOptionalAnimation {
- location = closestLocation(to: value.location, for: geometry)
- isMoving = false
- dragPosition = position(for: geometry)
- }
- resetIdle()
- longIdleTimeout.assertUserInteraction()
- }
- )
- .onAppear {
- resetIdle()
- longIdleTimeout.assertUserInteraction()
- if isCollapsed {
- withOptionalAnimation(.easeInOut(duration: 1)) {
- shake.toggle()
- }
- }
- }
- .onChange(of: state.isUserInteracting) { newValue in
- longIdleTimeout.assertUserInteraction()
- session.activeWindow = state.id
- }
- }
- }
-
- private func withOptionalAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result {
- if UIAccessibility.isReduceMotionEnabled {
- return try body()
- } else {
- return try withAnimation(animation, body)
- }
- }
-
- private func position(for geometry: GeometryProxy) -> CGPoint {
- let yoffset: CGFloat = 48
- var xoffset: CGFloat = 48
- guard !isMoving else {
- return dragPosition
- }
- if session.vm.hasUsbRedirection && !isCollapsed {
- xoffset -= 12
- }
- switch location {
- case .topRight:
- return CGPoint(x: geometry.size.width - xoffset, y: yoffset)
- case .bottomRight:
- return CGPoint(x: geometry.size.width - xoffset, y: geometry.size.height - yoffset)
- case .topLeft:
- return CGPoint(x: xoffset, y: yoffset)
- case .bottomLeft:
- return CGPoint(x: xoffset, y: geometry.size.height - yoffset)
- }
- }
-
- private func closestLocation(to point: CGPoint, for geometry: GeometryProxy) -> ToolbarLocation {
- if point.x < geometry.size.width/2 && point.y < geometry.size.height/2 {
- return .topLeft
- } else if point.x < geometry.size.width/2 && point.y > geometry.size.height/2 {
- return .bottomLeft
- } else if point.x > geometry.size.width/2 && point.y > geometry.size.height/2 {
- return .bottomRight
- } else {
- return .topRight
- }
- }
-
- private func offset(for index: Int) -> CGSize {
- var sub = 0
- if !session.vm.hasUsbRedirection && index >= 4 {
- sub = 1
- }
- let x = isCollapsed ? 0 : -CGFloat(index-sub)*spacing
- return CGSize(width: x, height: 0)
- }
-
- private func resetIdle() {
- if let task = shortIdleTask {
- task.cancel()
- }
- self.isIdle = false
- shortIdleTask = DispatchWorkItem {
- self.shortIdleTask = nil
- withOptionalAnimation {
- self.isIdle = true
- }
- }
- DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: shortIdleTask!)
- }
- }
- enum ToolbarLocation: Int {
- case topRight
- case bottomRight
- case topLeft
- case bottomLeft
- }
- protocol ToolbarButtonBaseStyle<Label, Content> {
- associatedtype Label: View
- associatedtype Content: View
-
- var horizontalSizeClass: UserInterfaceSizeClass? { get }
- var verticalSizeClass: UserInterfaceSizeClass? { get }
-
- func makeBodyBase(label: Label, isPressed: Bool) -> Content
- }
- extension ToolbarButtonBaseStyle {
- private var size: CGFloat {
- (horizontalSizeClass == .compact || verticalSizeClass == .compact) ? 32 : 48
- }
-
- func makeBodyBase(label: Label, isPressed: Bool) -> some View {
- ZStack {
- Circle()
- .foregroundColor(.gray)
- .opacity(isPressed ? 0.8 : 0.7)
- .blur(radius: 0.1)
- label
- .labelStyle(.iconOnly)
- .foregroundColor(isPressed ? .secondary : .white)
- .opacity(0.75)
- }.frame(width: size, height: size)
- .mask(Circle().frame(width: size-2, height: size-2))
- .scaleEffect(isPressed ? 1.2 : 1)
- .hoverEffect(.lift)
- }
- }
- struct ToolbarButtonStyle: ButtonStyle, ToolbarButtonBaseStyle {
- typealias Label = Configuration.Label
-
- @Environment(\.horizontalSizeClass) private var horizontalSizeClassEnvironment
- @Environment(\.verticalSizeClass) private var verticalSizeClassEnvironment
-
- var horizontalSizeClass: UserInterfaceSizeClass?
- var verticalSizeClass: UserInterfaceSizeClass?
-
- init(horizontalSizeClass: UserInterfaceSizeClass? = nil, verticalSizeClass: UserInterfaceSizeClass? = nil) {
- if horizontalSizeClass != nil {
- self.horizontalSizeClass = horizontalSizeClass
- } else {
- self.horizontalSizeClass = horizontalSizeClassEnvironment
- }
- if verticalSizeClass != nil {
- self.verticalSizeClass = verticalSizeClass
- } else {
- self.verticalSizeClass = verticalSizeClassEnvironment
- }
- }
-
- func makeBody(configuration: Configuration) -> some View {
- return makeBodyBase(label: configuration.label, isPressed: configuration.isPressed)
- }
- }
- struct ToolbarMenuStyle: MenuStyle, ToolbarButtonBaseStyle {
- typealias Label = Menu<Configuration.Label, Configuration.Content>
-
- @Environment(\.horizontalSizeClass) internal var horizontalSizeClass
- @Environment(\.verticalSizeClass) internal var verticalSizeClass
-
- func makeBody(configuration: Configuration) -> some View {
- return makeBodyBase(label: Menu(configuration), isPressed: false)
- }
- }
- // https://www.objc.io/blog/2019/10/01/swiftui-shake-animation/
- struct Shake: GeometryEffect {
- var amount: CGFloat = 8
- var shakesPerUnit = 3
- var animatableData: CGFloat
-
- init(shake: Bool) {
- animatableData = shake ? 1.0 : 0.0
- }
- func effectValue(size: CGSize) -> ProjectionTransform {
- ProjectionTransform(CGAffineTransform(translationX:
- amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
- y: 0))
- }
- }
- extension ButtonStyle where Self == ToolbarButtonStyle {
- static var toolbar: ToolbarButtonStyle {
- ToolbarButtonStyle()
- }
-
- // this is needed to workaround a SwiftUI bug on < iOS 15
- static func toolbar(horizontalSizeClass: UserInterfaceSizeClass?, verticalSizeClass: UserInterfaceSizeClass?) -> ToolbarButtonStyle {
- ToolbarButtonStyle(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
- }
- }
- extension MenuStyle where Self == ToolbarMenuStyle {
- static var toolbar: ToolbarMenuStyle {
- ToolbarMenuStyle()
- }
- }
- @MainActor private class LongIdleTimeout: ObservableObject {
- private var longIdleTask: DispatchWorkItem?
-
- @Published var isUserInteracting: Bool = true
-
- private func setIsUserInteracting(_ value: Bool) {
- if !UIAccessibility.isReduceMotionEnabled {
- withAnimation {
- self.isUserInteracting = value
- }
- } else {
- self.isUserInteracting = value
- }
- }
-
- func assertUserInteraction() {
- if let task = longIdleTask {
- task.cancel()
- }
- setIsUserInteracting(true)
- longIdleTask = DispatchWorkItem {
- self.longIdleTask = nil
- self.setIsUserInteracting(false)
- }
- DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: longIdleTask!)
- }
- }
|