VMConfigInfoView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. //
  2. // Copyright © 2020 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. private enum IconStyle: String, Identifiable, CaseIterable {
  18. case generic
  19. case operatingSystem
  20. case custom
  21. var text: Text {
  22. get {
  23. switch self {
  24. case .generic: return Text("Generic");
  25. case .operatingSystem: return Text("Operating System")
  26. case .custom: return Text("Custom")
  27. }
  28. }
  29. }
  30. var id: String { get { self.rawValue } }
  31. }
  32. struct VMConfigInfoView: View {
  33. @Binding var config: UTMConfigurationInfo
  34. @State private var imageSelectVisible: Bool = false
  35. @State private var iconStyle: IconStyle = .generic
  36. @State private var warningMessage: String? = nil
  37. var body: some View {
  38. VStack(alignment: .leading, spacing: 16) {
  39. #if os(macOS)
  40. HStack {
  41. Text("Name").frame(width: 50, alignment: .trailing)
  42. nameField
  43. }
  44. HStack(alignment: .top) {
  45. Text("Notes").frame(width: 50, alignment: .trailing)
  46. notesField
  47. }
  48. HStack {
  49. Text("Icon").frame(width: 50, alignment: .trailing)
  50. iconSelector
  51. .aspectRatio(1, contentMode: .fill)
  52. iconStylePicker
  53. }
  54. #else
  55. Form {
  56. Section(header: Text("Name")) {
  57. nameField
  58. }
  59. Section(header: Text("Notes")) {
  60. notesField
  61. }
  62. Section(header: Text("Icon")) {
  63. iconStylePicker
  64. iconSelector
  65. }
  66. }
  67. #endif
  68. }.onAppear {
  69. if config.isIconCustom {
  70. iconStyle = .custom
  71. } else if config.iconURL != nil {
  72. iconStyle = .operatingSystem
  73. }
  74. }.alert(item: $warningMessage) { warning in
  75. Alert(title: Text(warning))
  76. }.disableAutocorrection(true)
  77. }
  78. private var nameField: some View {
  79. TextField("Name", text: $config.name)
  80. .keyboardType(.asciiCapable)
  81. .lineLimit(1)
  82. }
  83. private var notesField: some View {
  84. TextEditor(text: $config.notes.bound)
  85. #if os(macOS)
  86. .border(Color.primary, width: 0.5)
  87. #endif
  88. .frame(minHeight: 200)
  89. }
  90. @ViewBuilder
  91. private var iconStylePicker: some View {
  92. let style = Binding<IconStyle> {
  93. return iconStyle
  94. } set: {
  95. iconStyle = $0
  96. config.isIconCustom = false
  97. config.iconURL = nil
  98. }
  99. Picker(selection: style.animation(), label: Text("Style")) {
  100. ForEach(IconStyle.allCases, id: \.rawValue) { value in
  101. value.text
  102. .tag(value)
  103. }
  104. }
  105. #if os(macOS)
  106. .pickerStyle(.radioGroup)
  107. .labelsHidden()
  108. #endif
  109. }
  110. @ViewBuilder
  111. private var iconSelector: some View {
  112. switch iconStyle {
  113. case .custom:
  114. #if os(macOS)
  115. VStack {
  116. IconPreview(url: config.iconURL)
  117. .onTapGesture {
  118. imageSelectVisible.toggle()
  119. }
  120. Button(action: { imageSelectVisible.toggle() }, label: {
  121. Text("Choose")
  122. }).fileImporter(isPresented: $imageSelectVisible, allowedContentTypes: [.image]) { result in
  123. switch result {
  124. case .success(let url):
  125. imageCustomSelected(url: url)
  126. case .failure:
  127. break
  128. }
  129. }
  130. }
  131. .frame(width: 90)
  132. #else
  133. Button(action: { imageSelectVisible.toggle() }, label: {
  134. IconPreview(url: config.iconURL)
  135. }).popover(isPresented: $imageSelectVisible, arrowEdge: .bottom) {
  136. ImagePicker(onImageSelected: imageCustomSelected)
  137. }.buttonStyle(.plain)
  138. #endif
  139. case .operatingSystem:
  140. #if os(macOS)
  141. VStack {
  142. IconPreview(url: config.iconURL)
  143. .onTapGesture {
  144. imageSelectVisible.toggle()
  145. }
  146. Button(action: { imageSelectVisible.toggle() }, label: {
  147. Text("Choose")
  148. }).popover(isPresented: $imageSelectVisible, arrowEdge: .bottom) {
  149. IconSelect(current: config.iconURL, onIconSelected: imageSelected)
  150. }
  151. }
  152. .frame(width: 90)
  153. #else
  154. IconSelect(current: config.iconURL, onIconSelected: imageSelected)
  155. #endif
  156. default:
  157. #if os(macOS)
  158. VStack {
  159. Image(systemName: "desktopcomputer")
  160. .resizable()
  161. .frame(width: 30.0, height: 30.0)
  162. .foregroundColor(Color(NSColor.disabledControlTextColor))
  163. Button {} label: {
  164. Text("Choose")
  165. }.disabled(true)
  166. }
  167. .frame(width: 90)
  168. #else
  169. EmptyView()
  170. #endif
  171. }
  172. }
  173. private func imageCustomSelected(url: URL?) {
  174. if let imageURL = url {
  175. config.iconURL = imageURL
  176. config.isIconCustom = true
  177. }
  178. imageSelectVisible = false
  179. }
  180. private func imageSelected(url: URL) {
  181. let name = url.deletingPathExtension().lastPathComponent
  182. config.iconURL = UTMConfigurationInfo.builtinIcon(named: name)
  183. config.isIconCustom = false
  184. imageSelectVisible = false
  185. }
  186. }
  187. private struct IconPreview: View {
  188. let url: URL?
  189. #if os(macOS)
  190. typealias PlatformImage = NSImage
  191. #else
  192. typealias PlatformImage = UIImage
  193. #endif
  194. var body: some View {
  195. HStack {
  196. #if !os(macOS)
  197. Spacer()
  198. #endif
  199. Logo(logo: PlatformImage(contentsOfURL: url))
  200. #if !os(macOS)
  201. Spacer()
  202. #endif
  203. }
  204. }
  205. }
  206. #if os(macOS)
  207. let iconGridSize: CGFloat = 80
  208. #else
  209. let iconGridSize: CGFloat = 100
  210. #endif
  211. private struct IconSelect: View {
  212. let current: URL?
  213. let onIconSelected: (URL) -> Void
  214. private let gridLayout = [GridItem(.adaptive(minimum: iconGridSize))]
  215. private var icons: [URL] {
  216. let paths = Bundle.main.paths(forResourcesOfType: "png", inDirectory: "Icons")
  217. let urls = paths.map({ URL(fileURLWithPath: $0) })
  218. return urls.sorted { urlA, urlB in
  219. urlA.lastPathComponent < urlB.lastPathComponent
  220. }
  221. }
  222. #if os(macOS)
  223. typealias PlatformImage = NSImage
  224. #else
  225. typealias PlatformImage = UIImage
  226. #endif
  227. struct IconSelectModifier: ViewModifier {
  228. @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
  229. func body(content: Content) -> some View {
  230. #if os(macOS)
  231. return AnyView(
  232. ScrollView {
  233. content.padding(16)
  234. }.frame(width: 480, height: 400)
  235. )
  236. #else
  237. return AnyView(content)
  238. #endif
  239. }
  240. }
  241. var body: some View {
  242. LazyVGrid(columns: gridLayout, spacing: 0) {
  243. ForEach(icons, id: \.self) { icon in
  244. Button(action: { onIconSelected(icon) }, label: {
  245. VStack(alignment: .center) {
  246. Logo(logo: PlatformImage(contentsOfURL: icon))
  247. Text(iconToTitle(icon))
  248. .lineLimit(2, optionalReservesSpace: true)
  249. .font(.footnote)
  250. .multilineTextAlignment(.center)
  251. }
  252. .padding(8)
  253. .frame(width: iconGridSize, height: iconGridSize)
  254. .overlay(
  255. RoundedRectangle(cornerRadius: 10)
  256. .stroke(current == icon ? Color.accentColor : Color.clear, lineWidth: 2)
  257. )
  258. }).buttonStyle(.plain)
  259. }
  260. }.modifier(IconSelectModifier())
  261. }
  262. }
  263. private extension View {
  264. @ViewBuilder
  265. func lineLimit(_ limit: Int, optionalReservesSpace: Bool) -> some View {
  266. if #available(macOS 13, iOS 16, *) {
  267. self.lineLimit(limit, reservesSpace: optionalReservesSpace)
  268. } else {
  269. self.lineLimit(limit)
  270. }
  271. }
  272. }
  273. struct VMConfigInfoView_Previews: PreviewProvider {
  274. @State static private var config = UTMConfigurationInfo()
  275. static var previews: some View {
  276. Group {
  277. VMConfigInfoView(config: $config)
  278. #if os(macOS)
  279. .scrollable()
  280. #endif
  281. IconSelect(current: nil) { _ in
  282. }
  283. }
  284. }
  285. }
  286. private func iconToTitle(_ icon: URL?) -> LocalizedStringKey {
  287. guard let fileName = icon?.deletingPathExtension().lastPathComponent else {
  288. return "Custom"
  289. }
  290. return ICON_TITLE_MAP[fileName] ?? "Custom"
  291. }
  292. private let ICON_TITLE_MAP: [String: LocalizedStringKey] = [
  293. "AIX": "AIX",
  294. "IOS": "iOS",
  295. "Windows7": "Windows 7",
  296. "almalinux": "AlmaLinux",
  297. "alpine": "Alpine",
  298. "amigaos": "AmigaOS",
  299. "android": "Android",
  300. "apple-tv": "Apple TV",
  301. "arch-linux": "Arch Linux",
  302. "backtrack": "BackTrack",
  303. "bada": "Bada",
  304. "beos": "BeOS",
  305. "centos": "CentOS",
  306. "chrome-os": "Chrome OS",
  307. "cyanogenmod": "CyanogenMod",
  308. "debian": "Debian",
  309. "elementary-os": "Elementary OS",
  310. "fedora": "Fedora",
  311. "firefox-os": "Firefox OS",
  312. "freebsd": "FreeBSD",
  313. "gentoo": "Gentoo",
  314. "haiku-os": "Haiku OS",
  315. "hp-ux": "HP-UX",
  316. "kaios": "KaiOS",
  317. "knoppix": "Knoppix",
  318. "kubuntu": "Kubuntu",
  319. "linux": "Linux",
  320. "lubuntu": "Lubuntu",
  321. "mac": "macOS",
  322. "maemo": "Maemo",
  323. "mandriva": "Mandriva",
  324. "meego": "MeeGo",
  325. "mint": "Linux Mint",
  326. "netbsd": "NetBSD",
  327. "nintendo": "Nintendo",
  328. "nixos": "NixOS",
  329. "openbsd": "OpenBSD",
  330. "openwrt": "OpenWrt",
  331. "os2": "OS/2",
  332. "palmos": "Palm OS",
  333. "playstation-portable": "PlayStation Portable",
  334. "playstation": "PlayStation",
  335. "pop-os": "Pop!_OS",
  336. "red-hat": "Red Hat",
  337. "remix-os": "Remix OS",
  338. "risc-os": "RISC OS",
  339. "sabayon": "Sabayon",
  340. "sailfish-os": "Sailfish OS",
  341. "slackware": "Slackware",
  342. "solaris": "Solaris",
  343. "suse": "openSUSE",
  344. "syllable": "Syllable",
  345. "symbian": "Symbian",
  346. "threadx": "ThreadX",
  347. "tizen": "Tizen",
  348. "ubuntu": "Ubuntu",
  349. "webos": "webOS",
  350. "windows-11": "Windows 11",
  351. "windows-9x": "Windows 9x",
  352. "windows-xp": "Windows XP",
  353. "windows": "Windows",
  354. "xbox": "Xbox",
  355. "xubuntu": "Xubuntu",
  356. "yunos": "YunOS",
  357. "pardus": "Pardus"
  358. ]