VMNavigationListView.swift 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. //
  2. // Copyright © 2022 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 VMNavigationListView: View {
  19. @EnvironmentObject private var data: UTMData
  20. var body: some View {
  21. if #available(iOS 16, macOS 13, *) {
  22. CompatibleNavigationSplitView {
  23. List(selection: $data.selectedVM) {
  24. listBody
  25. }.modifier(VMListModifier())
  26. } detail: {
  27. if let vm = data.selectedVM {
  28. VMDetailsView(vm: vm)
  29. } else {
  30. VMPlaceholderView()
  31. #if os(visionOS)
  32. .toolbar {
  33. UTMPreferenceButtonToolbarContent()
  34. }
  35. #endif
  36. }
  37. }.navigationSplitViewStyle(.balanced)
  38. } else {
  39. NavigationView {
  40. List {
  41. listBody
  42. }.modifier(VMListModifier())
  43. VMPlaceholderView()
  44. }
  45. }
  46. }
  47. @ViewBuilder private var listBody: some View {
  48. ForEach(data.virtualMachines) { vm in
  49. if !vm.isLoaded {
  50. UTMUnavailableVMView(vm: vm)
  51. } else {
  52. if #available(iOS 16, macOS 13, visionOS 1, *) {
  53. VMCardView(vm: vm)
  54. .modifier(VMContextMenuModifier(vm: vm))
  55. .tag(vm)
  56. } else {
  57. NavigationLink(
  58. destination: VMDetailsView(vm: vm),
  59. tag: vm,
  60. selection: $data.selectedVM,
  61. label: { VMCardView(vm: vm) })
  62. .modifier(VMContextMenuModifier(vm: vm))
  63. }
  64. }
  65. }.onMove(perform: move)
  66. #if !WITH_REMOTE // FIXME: implement remote feature
  67. .onDelete(perform: delete)
  68. #endif
  69. if data.pendingVMs.count > 0 {
  70. Section(header: Text("Pending")) {
  71. ForEach(data.pendingVMs, id: \.name) { vm in
  72. UTMPendingVMView(vm: vm)
  73. }.onDelete(perform: cancel)
  74. }.transition(.opacity)
  75. }
  76. }
  77. private func move(fromOffsets: IndexSet, toOffset: Int) {
  78. data.listMove(fromOffsets: fromOffsets, toOffset: toOffset)
  79. }
  80. private func delete(indexSet: IndexSet) {
  81. let selected = data.virtualMachines[indexSet]
  82. for vm in selected {
  83. data.busyWorkAsync {
  84. try await data.delete(vm: vm)
  85. }
  86. }
  87. }
  88. private func cancel(indexSet: IndexSet) {
  89. let selected = data.pendingVMs[indexSet]
  90. for vm in selected {
  91. data.cancelDownload(for: vm)
  92. }
  93. }
  94. }
  95. @available(iOS 16, macOS 13, *)
  96. private struct CompatibleNavigationSplitView<Sidebar, Detail> : View where Sidebar : View, Detail : View {
  97. @State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn
  98. let sidebar: () -> Sidebar
  99. let detail: () -> Detail
  100. init(@ViewBuilder sidebar: @escaping () -> Sidebar, @ViewBuilder detail: @escaping () -> Detail) {
  101. self.sidebar = sidebar
  102. self.detail = detail
  103. }
  104. var body: some View {
  105. NavigationSplitView(columnVisibility: $columnVisibility, sidebar: sidebar, detail: detail)
  106. }
  107. }
  108. private struct VMListModifier: ViewModifier {
  109. @EnvironmentObject private var data: UTMData
  110. @State private var settingsPresented = false
  111. @State private var sheetPresented = false
  112. @State private var donatePresented = false
  113. private let _donateTip: Any?
  114. private let _createTip: Any?
  115. @available(iOS 17, macOS 14, *)
  116. private var donateTip: UTMTipDonate {
  117. _donateTip as! UTMTipDonate
  118. }
  119. @available(iOS 17, macOS 14, *)
  120. private var createTip: UTMTipCreateVM {
  121. _createTip as! UTMTipCreateVM
  122. }
  123. init() {
  124. if #available(iOS 17, macOS 14, *) {
  125. _donateTip = UTMTipDonate()
  126. _createTip = UTMTipCreateVM()
  127. } else {
  128. _donateTip = nil
  129. _createTip = nil
  130. }
  131. }
  132. func body(content: Content) -> some View {
  133. content
  134. #if os(macOS)
  135. .frame(minWidth: 250, idealWidth: 350)
  136. #endif
  137. .listStyle(.sidebar)
  138. .navigationTitle(productName)
  139. #if os(macOS)
  140. .navigationSubtitle(data.selectedVM?.detailsTitleLabel ?? "")
  141. #endif
  142. .toolbar {
  143. #if os(macOS)
  144. ToolbarItem(placement: .navigation) {
  145. newButton
  146. }
  147. #else
  148. #if !WITH_REMOTE // FIXME: implement remote feature
  149. ToolbarItem(placement: .navigationBarLeading) {
  150. if #available(iOS 17, visionOS 99, *) {
  151. Button {
  152. createTip.invalidate(reason: .actionPerformed)
  153. data.newVM()
  154. } label: {
  155. Image(systemName: "plus") // SwiftUI bug: tip won't show up if this is a label
  156. }.help("Create a new VM")
  157. .popoverTip(createTip, arrowEdge: .top)
  158. } else {
  159. newButton
  160. }
  161. }
  162. #endif
  163. #if !WITH_REMOTE
  164. ToolbarItem(placement: .navigationBarLeading) {
  165. if #available(iOS 17, visionOS 99, *) {
  166. Button {
  167. donateTip.invalidate(reason: .actionPerformed)
  168. donatePresented.toggle()
  169. } label: {
  170. Image(systemName: "heart.fill") // SwiftUI bug: tip won't show up if this is a label
  171. }.popoverTip(donateTip, arrowEdge: .top) { action in
  172. donateTip.invalidate(reason: .actionPerformed)
  173. if action.id == "donate" {
  174. donatePresented.toggle()
  175. }
  176. }
  177. } else {
  178. Button {
  179. donatePresented.toggle()
  180. } label: {
  181. Label("Donate", systemImage: "heart.fill")
  182. }
  183. }
  184. }
  185. #endif
  186. #if !os(visionOS) && !WITH_REMOTE
  187. ToolbarItem(placement: .navigationBarTrailing) {
  188. Button("Settings") {
  189. settingsPresented.toggle()
  190. }
  191. }
  192. #endif
  193. ToolbarItem(placement: .navigationBarTrailing) {
  194. EditButton()
  195. }
  196. #endif
  197. }
  198. #if os(iOS)
  199. // SwiftUI bug on iOS 14.4 and previous versions prevents multiple .sheet from working
  200. .sheet(isPresented: $sheetPresented) {
  201. if data.showNewVMSheet {
  202. VMWizardView()
  203. } else if settingsPresented {
  204. #if !WITH_REMOTE
  205. UTMSettingsView()
  206. #endif
  207. } else if donatePresented {
  208. #if !os(macOS) && !WITH_REMOTE
  209. UTMDonateView()
  210. #endif
  211. }
  212. }
  213. .onChange(of: data.showNewVMSheet) { newValue in
  214. if newValue {
  215. settingsPresented = false
  216. donatePresented = false
  217. sheetPresented = true
  218. }
  219. }
  220. .onChange(of: settingsPresented) { newValue in
  221. if newValue {
  222. data.showNewVMSheet = false
  223. donatePresented = false
  224. sheetPresented = true
  225. }
  226. }
  227. .onChange(of: donatePresented) { newValue in
  228. if newValue {
  229. data.showNewVMSheet = false
  230. settingsPresented = false
  231. sheetPresented = true
  232. }
  233. }
  234. .onChange(of: sheetPresented) { newValue in
  235. if !newValue {
  236. settingsPresented = false
  237. donatePresented = false
  238. data.showNewVMSheet = false
  239. }
  240. }
  241. .onReceive(NSNotification.OpenVirtualMachine) { _ in
  242. sheetPresented = false
  243. }
  244. #else
  245. .sheet(isPresented: $data.showNewVMSheet) {
  246. VMWizardView()
  247. }
  248. #if !os(macOS) && !WITH_REMOTE
  249. .sheet(isPresented: $donatePresented) {
  250. UTMDonateView()
  251. }
  252. #endif
  253. .onReceive(NSNotification.OpenVirtualMachine) { _ in
  254. data.showNewVMSheet = false
  255. }
  256. #endif
  257. }
  258. private var newButton: some View {
  259. Button(action: { data.newVM() }, label: {
  260. Label("New VM", systemImage: "plus").labelStyle(.iconOnly)
  261. }).help("Create a new VM")
  262. }
  263. }