import AppKit import SwiftUI /// Settings window. Also the place where the active conversion engines are /// listed, so "why can't I export MKV?" has an answer in the app itself. final class PreferencesWindowController { static let shared = PreferencesWindowController() private var window: NSWindow? private init() {} func show() { if let window { NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) return } let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, height: 620), styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false ) window.title = "Konverter Settings" window.titlebarAppearsTransparent = true window.contentView = NSHostingView(rootView: PreferencesView()) window.center() window.isReleasedWhenClosed = false NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) self.window = window } } struct PreferencesView: View { @ObservedObject private var settings = Settings.shared @State private var loginItemError: String? var body: some View { TabView { general.tabItem { Label("General", systemImage: "gearshape") } wheel.tabItem { Label("Wheel", systemImage: "circle.hexagongrid") } engines.tabItem { Label("Engines", systemImage: "cpu") } } .padding(16) .frame(width: 520, height: 600) } private var general: some View { Form { Section { Toggle("Konverter is active", isOn: $settings.enabled) Toggle("Enable at login", isOn: Binding( get: { settings.openAtLogin }, set: { value in if LoginItem.set(value) { settings.openAtLogin = value loginItemError = nil } else { settings.openAtLogin = LoginItem.isEnabled loginItemError = "Could not update the login item. Move Konverter to /Applications and try again." } } )) if let loginItemError { Text(loginItemError) .font(.system(size: 10.5)) .foregroundStyle(.orange) } } header: { Text("Startup") } Section { Picker("Save converted files", selection: $settings.outputLocation) { ForEach(OutputLocation.allCases) { location in Text(location.title).tag(location) } } if settings.outputLocation == .chosenFolder { HStack { Text(settings.outputFolderPath.isEmpty ? "No folder chosen" : settings.outputFolderPath) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) Spacer() Button("Choose…") { chooseFolder() } } } Picker("When a file already exists", selection: $settings.conflictPolicy) { ForEach(ConflictPolicy.allCases) { policy in Text(policy.title).tag(policy) } } } header: { Text("Output") } Section { Toggle("Show the job window", isOn: $settings.showProgressWindow) Toggle("Reveal files in Finder when finished", isOn: $settings.revealWhenFinished) Toggle("Play a sound when finished", isOn: $settings.playSound) } header: { Text("Feedback") } } .formStyle(.grouped) } private var wheel: some View { Form { Section { Picker("Convert", selection: $settings.convertKey) { ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) } } Picker("Tools", selection: $settings.toolsKey) { ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) } } Picker("Actions", selection: $settings.actionsKey) { ForEach(TriggerKey.allCases) { key in Text(key.title).tag(key) } } Toggle("Tools also require Shift", isOn: $settings.requireShiftForTools) } header: { Text("Trigger keys") } footer: { Text("Start dragging a file in Finder, then hold a key. The wheel stays open until you drop or press Escape. Keys must differ, otherwise the first match wins (Actions, then Tools, then Convert).") .font(.system(size: 10.5)) .foregroundStyle(.secondary) } Section { Picker("Style", selection: $settings.wheelStyle) { ForEach(WheelStyle.allCases) { style in Text(style.title).tag(style) } } HStack { Text("Radius") Slider(value: $settings.wheelRadius, in: 120...260, step: 4) Text("\(Int(settings.wheelRadius))") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.secondary) .frame(width: 28) } Toggle("Show labels", isOn: $settings.showLabels) Toggle("Dim the background", isOn: $settings.dimBackground) } header: { Text("Appearance") } } .formStyle(.grouped) } private var engines: some View { Form { Section { Toggle("Use external tools", isOn: $settings.useExternalTools) Text("Konverter always works with Apple's frameworks. ffmpeg and LibreOffice only widen the format list.") .font(.system(size: 10.5)) .foregroundStyle(.secondary) } header: { Text("Conversion engines") } Section { engineRow(name: "Core (ImageIO, PDFKit, AVFoundation, textutil)", available: true, detail: "Always available") engineRow( name: "ffmpeg", available: Toolchain.hasFFmpeg, detail: Toolchain.ffmpeg ?? "Install with: brew install ffmpeg" ) engineRow( name: "LibreOffice", available: Toolchain.hasLibreOffice, detail: Toolchain.soffice ?? "Install with: brew install --cask libreoffice" ) } header: { Text("Detected") } Section { Text("Active: \(Toolchain.summary)") .font(.system(size: 11)) if let advice = Toolchain.missingToolAdvice { Text(advice) .font(.system(size: 10.5, design: .monospaced)) .foregroundStyle(.secondary) .textSelection(.enabled) } } header: { Text("Summary") } } .formStyle(.grouped) } private func engineRow(name: String, available: Bool, detail: String) -> some View { HStack(alignment: .top, spacing: 8) { Image(systemName: available ? "checkmark.circle.fill" : "circle.dashed") .foregroundStyle(available ? Color.green : Color.secondary) .font(.system(size: 12)) VStack(alignment: .leading, spacing: 1) { Text(name).font(.system(size: 12, weight: .medium)) Text(detail) .font(.system(size: 10, design: .monospaced)) .foregroundStyle(.secondary) .textSelection(.enabled) } } } private func chooseFolder() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.canCreateDirectories = true panel.allowsMultipleSelection = false panel.prompt = "Use Folder" NSApp.activate(ignoringOtherApps: true) if panel.runModal() == .OK, let url = panel.url { settings.outputFolderPath = url.path } } }