From 8f3ad216f9a3590d804f7c572e0ff94a90f13264 Mon Sep 17 00:00:00 2001 From: Jacob Sikorski Date: Fri, 3 Nov 2023 10:47:36 -0600 Subject: [PATCH] Add more settings and view fixes (#591) --- Whisky.xcodeproj/project.pbxproj | 4 + Whisky/Localizable.xcstrings | 168 +++++------------ Whisky/Models/Program.swift | 14 -- Whisky/Views/Bottle/BottleView.swift | 44 ++--- Whisky/Views/Bottle/ConfigView.swift | 58 +++--- Whisky/Views/Bottle/Pins/PinsView.swift | 41 ++-- Whisky/Views/ContentView.swift | 9 +- Whisky/Views/Programs/ProgramMenuView.swift | 48 +++++ Whisky/Views/Programs/ProgramsView.swift | 176 ++++++++++-------- .../Sources/WhiskyKit/Whisky/Bottle.swift | 12 ++ .../WhiskyKit/Whisky/BottleSettings.swift | 16 +- .../Sources/WhiskyKit/Whisky/Program.swift | 16 +- 12 files changed, 295 insertions(+), 311 deletions(-) create mode 100644 Whisky/Views/Programs/ProgramMenuView.swift diff --git a/Whisky.xcodeproj/project.pbxproj b/Whisky.xcodeproj/project.pbxproj index 8593d25e4..ac651418c 100644 --- a/Whisky.xcodeproj/project.pbxproj +++ b/Whisky.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 6EFDF6662AAE303300EF622F /* Icons.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6EFDF6652AAE303300EF622F /* Icons.xcassets */; }; 8C2AEFC82AED79B700CB568F /* WhiskyKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8C2AEFC72AED79B700CB568F /* WhiskyKit */; }; 8C2AEFCA2AED79CD00CB568F /* WhiskyKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8C2AEFC92AED79CD00CB568F /* WhiskyKit */; }; + 8C73E1342AF472FC00B6FB45 /* ProgramMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C73E1332AF472FC00B6FB45 /* ProgramMenuView.swift */; }; 8CB681E52AED7C6F0018D319 /* WhiskyKit in Resources */ = {isa = PBXBuildFile; fileRef = 8CB681E42AED7C6F0018D319 /* WhiskyKit */; }; 8CB681E72AED7CD00018D319 /* WhiskyKit in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB681E62AED7CD00018D319 /* WhiskyKit */; }; AB66A8642A4195B10006D238 /* Rosetta2.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB66A8632A4195B10006D238 /* Rosetta2.swift */; }; @@ -147,6 +148,7 @@ 6EFDF6522AAE2DA800EF622F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6EFDF6532AAE2DA800EF622F /* WhiskyThumbnail.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WhiskyThumbnail.entitlements; sourceTree = ""; }; 6EFDF6652AAE303300EF622F /* Icons.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Icons.xcassets; sourceTree = ""; }; + 8C73E1332AF472FC00B6FB45 /* ProgramMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramMenuView.swift; sourceTree = ""; }; 8CB681E42AED7C6F0018D319 /* WhiskyKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WhiskyKit; sourceTree = ""; }; AB66A8632A4195B10006D238 /* Rosetta2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Rosetta2.swift; sourceTree = ""; }; EEA5A2452A31DD65008274AE /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -211,6 +213,7 @@ 6E355E5929D782B2002D83BE /* ProgramsView.swift */, 6E355E5D29D7D85D002D83BE /* ProgramView.swift */, 6E17B6482AF4118F00831173 /* EnvironmentArgView.swift */, + 8C73E1332AF472FC00B6FB45 /* ProgramMenuView.swift */, ); path = Programs; sourceTree = ""; @@ -580,6 +583,7 @@ 6E17B6462AF3FDC100831173 /* PinsView.swift in Sources */, 6E064B1429DD331F00D9A2D2 /* SparkleView.swift in Sources */, 6E40495629CCA19C006E3F1B /* WhiskyApp.swift in Sources */, + 8C73E1342AF472FC00B6FB45 /* ProgramMenuView.swift in Sources */, 6E50D98329CD6066008C39F6 /* BottleVM.swift in Sources */, 6E6915452A3265BB0085BBB7 /* Logger.swift in Sources */, 6E49E0212AECB7DB00009CAC /* SettingsView.swift in Sources */, diff --git a/Whisky/Localizable.xcstrings b/Whisky/Localizable.xcstrings index b3aefcbb0..2871d016c 100644 --- a/Whisky/Localizable.xcstrings +++ b/Whisky/Localizable.xcstrings @@ -945,6 +945,16 @@ } } }, + "button.pin" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin" + } + } + } + }, "button.refresh" : { "localizations" : { "da" : { @@ -2125,6 +2135,16 @@ } } }, + "button.unpin" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unpin" + } + } + } + }, "button.winetricks" : { "localizations" : { "da" : { @@ -8262,124 +8282,6 @@ } } }, - "pin.unpin" : { - "localizations" : { - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unpin Program" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Programm lösen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unpin Program" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unpin Program" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Poista ohjelman kiinnitys" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unpin Program" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sblocca Programma" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ピン留めを解除" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "프로그램 고정 해제" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pin verwijderen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odepnij program" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Desafixar programa" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unpin Program" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Открепить программу" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sabitlenmiş Programı Kaldır" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Відкріпити програму" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unpin Program" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "取消固定此应用" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "取消程式釘選" - } - } - } - }, "program.add.blocklist" : { "localizations" : { "da" : { @@ -8498,6 +8400,16 @@ } } }, + "program.add.selected.blocklist" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Selected to Blocklist" + } + } + } + }, "program.args" : { "localizations" : { "da" : { @@ -9088,6 +9000,26 @@ } } }, + "program.remove.selected.blocklist" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Selected from Blocklist" + } + } + } + }, + "program.settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, "program.title" : { "localizations" : { "da" : { diff --git a/Whisky/Models/Program.swift b/Whisky/Models/Program.swift index 3bef186c5..bbc18ec84 100644 --- a/Whisky/Models/Program.swift +++ b/Whisky/Models/Program.swift @@ -90,18 +90,4 @@ extension Program { } } } - - func togglePinned() -> Bool { - if pinned { - bottle.settings.pins.removeAll(where: { $0.url == url }) - pinned = false - } else { - bottle.settings.pins.append(PinnedProgram(name: name - .replacingOccurrences(of: ".exe", with: ""), - url: url)) - pinned = true - } - - return pinned - } } diff --git a/Whisky/Views/Bottle/BottleView.swift b/Whisky/Views/Bottle/BottleView.swift index ded24a25a..0aef4d346 100644 --- a/Whisky/Views/Bottle/BottleView.swift +++ b/Whisky/Views/Bottle/BottleView.swift @@ -26,13 +26,9 @@ enum BottleStage { } struct BottleView: View { - @Binding var bottle: Bottle + @ObservedObject var bottle: Bottle @State private var path = NavigationPath() @State var programLoading: Bool = false - @State var pins: [PinnedProgram] = [] - // We don't actually care about the value - // This just provides a way to trigger a refresh - @State var loadStartMenu: Bool = false @State var showWinetricksSheet: Bool = false private let gridLayout = [GridItem(.adaptive(minimum: 100, maximum: .infinity))] @@ -40,13 +36,13 @@ struct BottleView: View { var body: some View { NavigationStack(path: $path) { ScrollView { - if pins.count > 0 { + let pinnedPrograms = bottle.programs.pinned + if pinnedPrograms.count > 0 { LazyVGrid(columns: gridLayout, alignment: .center) { - ForEach(pins, id: \.url) { pin in - PinsView(bottle: bottle, - pin: pin, - loadStartMenu: $loadStartMenu, - path: $path) + ForEach(bottle.settings.pins, id: \.url) { pin in + PinsView( + bottle: bottle, pin: pin, path: $path + ) } } .padding() @@ -72,12 +68,6 @@ struct BottleView: View { } } .formStyle(.grouped) - .onAppear { - updateStartMenu() - } - .onChange(of: loadStartMenu) { - updateStartMenu() - } } .bottomBar { HStack { @@ -130,19 +120,27 @@ struct BottleView: View { } .padding() } + .onAppear { + updateStartMenu() + } .disabled(!bottle.isActive) .navigationTitle(bottle.settings.name) .sheet(isPresented: $showWinetricksSheet) { WinetricksView(bottle: bottle) } + .onChange(of: bottle.settings, { oldValue, newValue in + guard oldValue != newValue else { return } + // Trigger a reload + BottleVM.shared.bottles = BottleVM.shared.bottles + }) .navigationDestination(for: BottleStage.self) { stage in switch stage { case .config: - ConfigView(bottle: $bottle) + ConfigView(bottle: bottle) case .programs: - ProgramsView(bottle: bottle, - reloadStartMenu: $loadStartMenu, - path: $path) + ProgramsView( + bottle: bottle, path: $path + ) } } .navigationDestination(for: Program.self) { program in @@ -151,7 +149,7 @@ struct BottleView: View { } } - func updateStartMenu() { + private func updateStartMenu() { bottle.programs = bottle.updateInstalledPrograms() let startMenuPrograms = bottle.getStartMenuPrograms() for startMenuProgram in startMenuPrograms { @@ -166,8 +164,6 @@ struct BottleView: View { } } } - - pins = bottle.settings.pins } } diff --git a/Whisky/Views/Bottle/ConfigView.swift b/Whisky/Views/Bottle/ConfigView.swift index caeb30a5b..446fa8ac9 100644 --- a/Whisky/Views/Bottle/ConfigView.swift +++ b/Whisky/Views/Bottle/ConfigView.swift @@ -27,7 +27,7 @@ enum LoadingState { } struct ConfigView: View { - @Binding var bottle: Bottle + @ObservedObject var bottle: Bottle @State private var buildVersion: Int = 0 @State private var retinaMode: Bool = false @State private var dpiConfig: Int = 96 @@ -43,14 +43,14 @@ struct ConfigView: View { var body: some View { Form { Section("config.title.wine", isExpanded: $wineSectionExpanded) { - SettingItemView(title: "config.winVersion", loadingState: $winVersionLoadingState) { + SettingItemView(title: "config.winVersion", loadingState: winVersionLoadingState) { Picker("config.winVersion", selection: $bottle.settings.windowsVersion) { ForEach(WinVersion.allCases.reversed(), id: \.self) { Text($0.pretty()) } } } - SettingItemView(title: "config.buildVersion", loadingState: $buildVersionLoadingState) { + SettingItemView(title: "config.buildVersion", loadingState: buildVersionLoadingState) { TextField("config.buildVersion", value: $buildVersion, formatter: NumberFormatter()) .multilineTextAlignment(.trailing) .textFieldStyle(PlainTextFieldStyle()) @@ -67,7 +67,7 @@ struct ConfigView: View { } } } - SettingItemView(title: "config.retinaMode", loadingState: $retinaModeLoadingState) { + SettingItemView(title: "config.retinaMode", loadingState: retinaModeLoadingState) { Toggle("config.retinaMode", isOn: $retinaMode) .onChange(of: retinaMode, { _, newValue in Task(priority: .userInitiated) { @@ -87,7 +87,7 @@ struct ConfigView: View { Text("config.enhacnedSync.esync").tag(EnhancedSync.esync) Text("config.enhacnedSync.msync").tag(EnhancedSync.msync) } - SettingItemView(title: "config.dpi", loadingState: $dpiConfigLoadingState) { + SettingItemView(title: "config.dpi", loadingState: dpiConfigLoadingState) { Button("config.inspect") { dpiSheetPresented = true } @@ -301,47 +301,37 @@ struct DPIConfigSheetView: View { } } -struct SettingItemView: View { - var title: String.LocalizationValue - @Binding var loadingState: LoadingState - @ViewBuilder var content: () -> V +struct SettingItemView: View { + let title: String.LocalizationValue + let loadingState: LoadingState + @ViewBuilder var content: () -> Content @Namespace private var viewId @Namespace private var progressViewId var body: some View { - ZStack { - if loadingState == .failed { - HStack { - Text(String(localized: title)) - Spacer() - Text("config.notAvailable").opacity(0.5) - } - } else if loadingState == .loading { - HStack { - Text(String(localized: title)) - Spacer() + HStack { + Text(String(localized: title)) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + switch loadingState { + case .loading, .modifying: ProgressView() .progressViewStyle(.circular) .controlSize(.small) .matchedGeometryEffect(id: progressViewId, in: viewId) - } - } else { - HStack(spacing: 16) { - Text(String(localized: title)) - Spacer() - if loadingState == .modifying { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.small) - .matchedGeometryEffect(id: progressViewId, in: viewId) - } + case .success: content() .labelsHidden() - .disabled(loadingState == .modifying) + .disabled(loadingState != .success) + case .failed: + Text("config.notAvailable") + .font(.caption).foregroundStyle(.red) + .multilineTextAlignment(.trailing) } - } + }.animation(.default, value: loadingState) } - .animation(.default, value: loadingState) } } diff --git a/Whisky/Views/Bottle/Pins/PinsView.swift b/Whisky/Views/Bottle/Pins/PinsView.swift index bc306aa39..77869ede3 100644 --- a/Whisky/Views/Bottle/Pins/PinsView.swift +++ b/Whisky/Views/Bottle/Pins/PinsView.swift @@ -20,16 +20,16 @@ import SwiftUI import WhiskyKit struct PinsView: View { - var bottle: Bottle + @ObservedObject var bottle: Bottle @State var pin: PinnedProgram - @State var program: Program? - @State var image: NSImage? - @State var showRenameSheet = false - @State var name: String = "" - @State var opening: Bool = false - @Binding var loadStartMenu: Bool @Binding var path: NavigationPath + @State private var program: Program? + @State private var image: NSImage? + @State private var showRenameSheet = false + @State private var name: String = "" + @State private var opening: Bool = false + var body: some View { VStack { Group { @@ -63,25 +63,12 @@ struct PinsView: View { .padding(EdgeInsets(top: 0, leading: 0, bottom: 12, trailing: 0)) } .contextMenu { - Button("button.run") { - runProgram() - } - Divider() - Button("program.config") { - if let program { - path.append(program) - } - } - Divider() - Button("button.rename") { - showRenameSheet.toggle() - } - Button("pin.unpin") { - bottle.settings.pins.removeAll(where: { $0.url == pin.url }) - for program in bottle.programs where program.url == pin.url { - program.pinned = false - } - loadStartMenu.toggle() + if let program = program { + ProgramMenuView(program: program, path: $path) + + Button("button.rename", systemImage: "pencil.line") { + showRenameSheet.toggle() + }.labelStyle(.titleAndIcon) } } .onTapGesture(count: 2) { @@ -92,7 +79,7 @@ struct PinsView: View { } .onAppear { name = pin.name - Task.detached { + Task.detached { @MainActor in program = bottle.programs.first(where: { $0.url == pin.url }) if let program { if let peFile = program.peFile { diff --git a/Whisky/Views/ContentView.swift b/Whisky/Views/ContentView.swift index 0b48fab48..4a3d23d5b 100644 --- a/Whisky/Views/ContentView.swift +++ b/Whisky/Views/ContentView.swift @@ -66,14 +66,7 @@ struct ContentView: View { } detail: { if let bottle = selected { if let bottle = bottleVM.bottles.first(where: { $0.url == bottle }) { - BottleView(bottle: Binding(get: { - // swiftlint:disable:next force_unwrapping - bottleVM.bottles[bottleVM.bottles.firstIndex(of: bottle)!] - }, set: { newValue in - if let index = bottleVM.bottles.firstIndex(of: bottle) { - bottleVM.bottles[index] = newValue - } - })) + BottleView(bottle: bottle) .disabled(bottle.inFlight) .id(bottle.url) } diff --git a/Whisky/Views/Programs/ProgramMenuView.swift b/Whisky/Views/Programs/ProgramMenuView.swift new file mode 100644 index 000000000..3dd329884 --- /dev/null +++ b/Whisky/Views/Programs/ProgramMenuView.swift @@ -0,0 +1,48 @@ +// +// ProgramMenuView.swift +// Whisky +// +// This file is part of Whisky. +// +// Whisky is free software: you can redistribute it and/or modify it under the terms +// of the GNU General Public License as published by the Free Software Foundation, +// either version 3 of the License, or (at your option) any later version. +// +// Whisky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along with Whisky. +// If not, see https://www.gnu.org/licenses/. +// + +import SwiftUI +import WhiskyKit + +struct ProgramMenuView: View { + @ObservedObject var program: Program + @Binding var path: NavigationPath + + var body: some View { + Button("button.run", systemImage: "play", action: { + Task { + await program.run() + } + }).labelStyle(.titleAndIcon) + + Section("program.settings") { + Button("program.config", systemImage: "gearshape", action: { + path.append(program) + }).labelStyle(.titleAndIcon) + + let buttonName = program.pinned + ? String(localized: "button.unpin") + : String(localized: "button.pin") + + let iconName = program.pinned ? "pin.slash" : "pin" + Button(buttonName, systemImage: iconName, action: { + program.pinned.toggle() + }).labelStyle(.titleAndIcon) + } + } +} diff --git a/Whisky/Views/Programs/ProgramsView.swift b/Whisky/Views/Programs/ProgramsView.swift index e57d62dc1..a45e52d9c 100644 --- a/Whisky/Views/Programs/ProgramsView.swift +++ b/Whisky/Views/Programs/ProgramsView.swift @@ -20,101 +20,132 @@ import SwiftUI import WhiskyKit struct ProgramsView: View { - let bottle: Bottle - @State var programs: [Program] = [] - @State var blocklist: [URL] = [] + @ObservedObject var bottle: Bottle + @State private var blocklist: [URL] = [] @State private var selectedPrograms = Set() @State private var selectedBlockitems = Set() - // We don't actually care about the value - // This just provides a way to trigger a refresh - @State var resortPrograms: Bool = false - @State var isExpanded: Bool = true - @State var isBlocklistExpanded: Bool = false - @Binding var reloadStartMenu: Bool @Binding var path: NavigationPath + @State private var sortedPrograms: [Program] = [] + @State private var resortPrograms = false + @State private var searchText = "" + + @AppStorage("areProgramsExpanded") private var areProgramsExpanded = true + @AppStorage("isBlocklistExpanded") private var isBlocklistExpanded = false + + private var searchResults: [Program] { + guard !searchText.isEmpty else { return sortedPrograms } + return sortedPrograms.filter({ $0.name.localizedCaseInsensitiveContains(searchText) }) + } + + private var searchedBlocklists: [URL] { + guard !searchText.isEmpty else { return blocklist } + return blocklist.filter({ $0.absoluteString.localizedCaseInsensitiveContains(searchText) }) + } + + private var selectedSearchedPrograms: [Program] { + searchResults.filter({ selectedPrograms.contains($0) }) + } var body: some View { Form { - Section("program.title", isExpanded: $isExpanded) { - List($programs, id: \.self, selection: $selectedPrograms) { $program in - ProgramItemView(program: program, - resortPrograms: $resortPrograms, - path: $path) - } - .contextMenu { - Button("program.add.blocklist") { - bottle.settings.blocklist.append(contentsOf: selectedPrograms.map { $0.url }) - resortPrograms.toggle() + Section("program.title", isExpanded: $areProgramsExpanded) { + List(searchResults, id: \.self, selection: $selectedPrograms) { program in + ProgramItemView( + bottle: bottle, program: program, path: $path + ) + .contextMenu { + let selectedPrograms = selectedSearchedPrograms + if selectedPrograms.contains(program) && selectedPrograms.count > 1 { + Button("program.add.selected.blocklist", systemImage: "hand.raised") { + bottle.settings.blocklist.append(contentsOf: selectedPrograms.map { $0.url }) + blocklist = bottle.settings.blocklist + }.labelStyle(.titleAndIcon) + } else { + ProgramMenuView(program: program, path: $path) + + Section { + Button("program.add.blocklist", systemImage: "hand.raised") { + bottle.settings.blocklist.append(program.url) + blocklist = bottle.settings.blocklist + }.labelStyle(.titleAndIcon) + } + } } } - } + }.animation(.easeInOut(duration: 0.2), value: sortedPrograms) + Section("program.blocklist", isExpanded: $isBlocklistExpanded) { - List($blocklist, id: \.self, selection: $selectedBlockitems) { $blockedUrl in - BlocklistItemView(blockedUrl: blockedUrl, - bottle: bottle, - resortPrograms: $resortPrograms) - } - .contextMenu { - Button("program.remove.blocklist") { - bottle.settings.blocklist.removeAll(where: { selectedBlockitems.contains($0) }) - resortPrograms.toggle() + List(searchedBlocklists, id: \.self, selection: $selectedBlockitems) { blockedUrl in + BlocklistItemView( + blockedUrl: blockedUrl, bottle: bottle + ) + .contextMenu { + if selectedBlockitems.contains(blockedUrl) { + Button("program.remove.selected.blocklist", systemImage: "hand.raised.slash") { + bottle.settings.blocklist.removeAll(where: { selectedBlockitems.contains($0) }) + blocklist = bottle.settings.blocklist + }.labelStyle(.titleAndIcon) + } else { + Button("program.remove.blocklist", systemImage: "hand.raised.slash") { + bottle.settings.blocklist.removeAll(where: { $0 == blockedUrl }) + blocklist = bottle.settings.blocklist + }.labelStyle(.titleAndIcon) + } } } } } .formStyle(.grouped) - .animation(.easeInOut(duration: 0.2), value: programs) - .animation(.easeInOut(duration: 0.2), value: isExpanded) + .animation(.easeInOut(duration: 0.2), value: areProgramsExpanded) .animation(.easeInOut(duration: 0.2), value: isBlocklistExpanded) .navigationTitle("tab.programs") + .searchable(text: $searchText) .onAppear { - programs = bottle.updateInstalledPrograms() - blocklist = bottle.settings.blocklist - sortPrograms() + loadData() } .onChange(of: resortPrograms) { - reloadStartMenu.toggle() - programs = bottle.updateInstalledPrograms() - blocklist = bottle.settings.blocklist - sortPrograms() + loadPrograms() + } + .onChange(of: bottle.settings) { + loadData() } } - func sortPrograms() { - var favourites = programs.filter { $0.pinned } - var nonFavourites = programs.filter { !$0.pinned } - favourites = favourites.sorted { $0.name < $1.name } - nonFavourites = nonFavourites.sorted { $0.name < $1.name } - programs.removeAll() - programs.append(contentsOf: favourites) - programs.append(contentsOf: nonFavourites) + private func loadData() { + loadPrograms() + blocklist = bottle.settings.blocklist + } + + private func loadPrograms() { + sortedPrograms = [ + bottle.programs.pinned.sorted { $0.name < $1.name }, + bottle.programs.unpinned.sorted { $0.name < $1.name } + ].flatMap { $0 } } } struct ProgramItemView: View { - let program: Program - @State var showButtons: Bool = false - @State var isPinned: Bool = false - @State var pinHovered: Bool = false - @Binding var resortPrograms: Bool + @ObservedObject var bottle: Bottle + @ObservedObject var program: Program @Binding var path: NavigationPath + @State private var showButtons = false + @State private var pinHovered = false var body: some View { HStack { Button { - isPinned = program.togglePinned() - resortPrograms.toggle() + program.pinned.toggle() } label: { - Image(systemName: isPinned ? pinHovered ? "pin.slash.fill" : "pin.fill" : "pin") + Image(systemName: program.pinned ? pinHovered ? "pin.slash.fill" : "pin.fill" : "pin") .onHover { hover in pinHovered = hover } } .buttonStyle(.plain) - .foregroundColor(isPinned ? .accentColor : .secondary) - .opacity(isPinned ? 1 : showButtons ? 1 : 0) + .foregroundColor(program.pinned ? .accentColor : .secondary) + .opacity(program.pinned ? 1 : showButtons ? 1 : 0) Text(program.name) - Spacer() + .frame(maxWidth: .infinity, alignment: .leading) if showButtons { if let peFile = program.peFile, let archString = peFile.architecture.toString() { @@ -126,21 +157,20 @@ struct ProgramItemView: View { .stroke(.secondary) ) } - Button { + + Button("program.config", systemImage: "gearshape", action: { path.append(program) - } label: { - Image(systemName: "gearshape") - } + }) + .labelStyle(.iconOnly) .buttonStyle(.plain) .foregroundStyle(.secondary) .help("program.config") - Button { + Button("button.run", systemImage: "play", action: { Task { await program.run() } - } label: { - Image(systemName: "play") - } + }) + .labelStyle(.iconOnly) .buttonStyle(.plain) .foregroundStyle(.secondary) .help("button.run") @@ -150,29 +180,23 @@ struct ProgramItemView: View { .onHover { hover in showButtons = hover } - .onAppear { - isPinned = program.pinned - } } } struct BlocklistItemView: View { let blockedUrl: URL - let bottle: Bottle - @State var showButtons: Bool = false - @Binding var resortPrograms: Bool + @ObservedObject var bottle: Bottle + @State private var showButtons: Bool = false var body: some View { HStack { Text(blockedUrl.prettyPath(bottle)) Spacer() if showButtons { - Button { + Button("program.remove.blocklist", systemImage: "xmark.circle.fill", action: { bottle.settings.blocklist.removeAll { $0 == blockedUrl } - resortPrograms.toggle() - } label: { - Image(systemName: "xmark.circle") - } + }) + .labelStyle(.iconOnly) .buttonStyle(.plain) .foregroundColor(.secondary) .help("program.remove.blocklist") diff --git a/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle.swift b/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle.swift index 7c958ee2b..098ac75f2 100644 --- a/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle.swift +++ b/WhiskyKit/Sources/WhiskyKit/Whisky/Bottle.swift @@ -78,3 +78,15 @@ extension Array where Element == Bottle { self.sort { $0.isActive && !$1.isActive } } } + +public extension Sequence where Iterator.Element == Program { + /// Filter all pinned programs + var pinned: [Program] { + return self.filter({ $0.pinned }) + } + + /// Filter all unpinned programs + var unpinned: [Program] { + return self.filter({ !$0.pinned }) + } +} diff --git a/WhiskyKit/Sources/WhiskyKit/Whisky/BottleSettings.swift b/WhiskyKit/Sources/WhiskyKit/Whisky/BottleSettings.swift index af4829d51..ffe9d8d59 100644 --- a/WhiskyKit/Sources/WhiskyKit/Whisky/BottleSettings.swift +++ b/WhiskyKit/Sources/WhiskyKit/Whisky/BottleSettings.swift @@ -20,7 +20,7 @@ import Foundation import SemanticVersion import os.log -public struct PinnedProgram: Codable, Hashable { +public struct PinnedProgram: Codable, Hashable, Equatable { public var name: String public var url: URL? @@ -36,7 +36,7 @@ public struct PinnedProgram: Codable, Hashable { } } -public struct BottleInfo: Codable { +public struct BottleInfo: Codable, Equatable { var name: String = "Bottle" var pins: [PinnedProgram] = [] var blocklist: [URL] = [] @@ -74,11 +74,11 @@ public enum WinVersion: String, CaseIterable, Codable { } } -public enum EnhancedSync: Codable { +public enum EnhancedSync: Codable, Equatable { case none, esync, msync } -public struct BottleWineConfig: Codable { +public struct BottleWineConfig: Codable, Equatable { static let defaultWineVersion = SemanticVersion(7, 7, 0) var wineVersion: SemanticVersion = Self.defaultWineVersion var windowsVersion: WinVersion = .win10 @@ -96,7 +96,7 @@ public struct BottleWineConfig: Codable { // swiftlint:enable line_length } -public struct BottleMetalConfig: Codable { +public struct BottleMetalConfig: Codable, Equatable { var metalHud: Bool = false var metalTrace: Bool = false @@ -109,11 +109,11 @@ public struct BottleMetalConfig: Codable { } } -public enum DXVKHUD: Codable { +public enum DXVKHUD: Codable, Equatable { case full, partial, fps, off } -public struct BottleDXVKConfig: Codable { +public struct BottleDXVKConfig: Codable, Equatable { var dxvk: Bool = false var dxvkAsync: Bool = true var dxvkHud: DXVKHUD = .off @@ -128,7 +128,7 @@ public struct BottleDXVKConfig: Codable { } } -public struct BottleSettings: Codable { +public struct BottleSettings: Codable, Equatable { static let defaultFileVersion = SemanticVersion(1, 0, 0) var fileVersion: SemanticVersion = Self.defaultFileVersion diff --git a/WhiskyKit/Sources/WhiskyKit/Whisky/Program.swift b/WhiskyKit/Sources/WhiskyKit/Whisky/Program.swift index 5e9991806..8c6e4ee86 100644 --- a/WhiskyKit/Sources/WhiskyKit/Whisky/Program.swift +++ b/WhiskyKit/Sources/WhiskyKit/Whisky/Program.swift @@ -29,7 +29,7 @@ public class Program: Hashable, ObservableObject { return hasher.combine(url) } - public var name: String + public let name: String public let bottle: Bottle public let url: URL public let settingsURL: URL @@ -38,7 +38,19 @@ public class Program: Hashable, ObservableObject { didSet { saveSettings() } } - @Published public var pinned: Bool + @Published public var pinned: Bool { + didSet { + if pinned { + bottle.settings.pins.append(PinnedProgram( + name: name.replacingOccurrences(of: ".exe", with: ""), + url: url + )) + } else { + bottle.settings.pins.removeAll(where: { $0.url == url }) + } + } + } + public var peFile: PEFile? public init(name: String, url: URL, bottle: Bottle) {