diff --git a/Sources/ClickLight/AppDelegate.swift b/Sources/ClickLight/AppDelegate.swift index 0b4825f..41312ad 100644 --- a/Sources/ClickLight/AppDelegate.swift +++ b/Sources/ClickLight/AppDelegate.swift @@ -6,12 +6,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let settingsStore = SettingsStore() private let activityStore = ClickActivityStore() + private let profileStore = ClickProfileStore() private var settingsWindowController: SettingsWindowController? private let hotKeyManager = HotKeyManager() private lazy var overlayCoordinator = OverlayCoordinator(settingsStore: settingsStore) private lazy var captureController = ClickCaptureController(settingsStore: settingsStore, eventTap: eventTap) private lazy var statusController = StatusController( settingsStore: settingsStore, + profileStore: profileStore, activityStore: activityStore, permissions: permissions, launchAtLogin: launchAtLogin, @@ -213,6 +215,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func openSettings() { let controller = settingsWindowController ?? SettingsWindowController( settingsStore: settingsStore, + profileStore: profileStore, activityStore: activityStore, launchAtLogin: launchAtLogin, permissions: permissions, diff --git a/Sources/ClickLight/ClickLightSettingsView.swift b/Sources/ClickLight/ClickLightSettingsView.swift index 599f5e4..591fc5a 100644 --- a/Sources/ClickLight/ClickLightSettingsView.swift +++ b/Sources/ClickLight/ClickLightSettingsView.swift @@ -1,13 +1,17 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers struct ClickLightSettingsView: View { @ObservedObject var viewModel: ClickLightSettingsViewModel + @ObservedObject var profileStore: ClickProfileStore @ObservedObject var activityStore: ClickActivityStore @State private var selectedPane: SettingsPane = .general @State private var showResetConfirmation = false @State private var showShortcutResetConfirmation = false @State private var showActivityResetConfirmation = false + @State private var profileName = "" + @State private var profileStatusMessage: String? var body: some View { NavigationSplitView { @@ -63,6 +67,8 @@ struct ClickLightSettingsView: View { stylePane case .shortcuts: shortcutsPane + case .profiles: + profilesPane case .events: eventsPane case .activity: @@ -632,6 +638,98 @@ struct ClickLightSettingsView: View { } } + private var profilesPane: some View { + VStack(spacing: 16) { + SettingsCard( + title: "Profiles", + subtitle: "Save reusable visual setups. Profiles do not include hotkeys, launch at login, menu layout, or activity history." + ) { + VStack(spacing: 0) { + ModernRow( + title: "Save Current Settings", + subtitle: "Use the current click, laser pointer, and shortcut-display settings." + ) { + HStack(spacing: 8) { + TextField("Profile name", text: $profileName) + .textFieldStyle(.roundedBorder) + .frame(width: 180) + Button { + saveCurrentProfile() + } label: { + Label("Save", systemImage: "square.and.arrow.down") + } + .disabled(profileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + if !profileStore.profiles.isEmpty { + Divider().padding(.vertical, 6) + } + + ForEach(profileStore.profiles) { profile in + ModernRow( + title: profile.name, + subtitle: "Created \(profile.createdAt.formatted(date: .abbreviated, time: .shortened))" + ) { + HStack(spacing: 8) { + Button { + viewModel.applyProfile(profile) + } label: { + Label("Apply", systemImage: "checkmark.circle") + } + .disabled(isCurrentProfile(profile)) + Button(role: .destructive) { + profileStore.delete(profile) + profileStatusMessage = "Deleted \(profile.name)." + } label: { + Label("Delete", systemImage: "trash") + } + } + } + if profile.id != profileStore.profiles.last?.id { + Divider().padding(.vertical, 6) + } + } + + if profileStore.profiles.isEmpty { + Text("No profiles yet.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + } + } + } + + SettingsCard(title: "Import and Export", subtitle: "Move profiles between Macs with a JSON file.") { + ModernRow(title: "Profiles File", + subtitle: "Exports all saved profiles, not activity or app-level settings.") { + HStack(spacing: 8) { + Button { + exportProfiles() + } label: { + Label("Export", systemImage: "square.and.arrow.up") + } + .disabled(profileStore.profiles.isEmpty) + + Button { + importProfiles() + } label: { + Label("Import", systemImage: "square.and.arrow.down") + } + } + } + } + + if let profileStatusMessage { + Text(profileStatusMessage) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + private var activityPane: some View { VStack(spacing: 16) { SettingsCard( @@ -715,6 +813,46 @@ struct ClickLightSettingsView: View { ) } + private func saveCurrentProfile() { + guard let profile = profileStore.saveProfile(named: profileName, from: viewModel.settings) else { return } + profileName = "" + profileStatusMessage = "Saved \(profile.name)." + } + + private func isCurrentProfile(_ profile: ClickSettingsProfile) -> Bool { + profile.settings == ClickProfileSettings(settings: viewModel.settings) + } + + private func exportProfiles() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "ClickLight Profiles.json" + panel.canCreateDirectories = true + + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + try profileStore.exportProfiles(to: url) + profileStatusMessage = "Exported \(profileStore.profiles.count) profile\(profileStore.profiles.count == 1 ? "" : "s")." + } catch { + profileStatusMessage = "Could not export profiles: \(error.localizedDescription)" + } + } + + private func importProfiles() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let count = try profileStore.importProfiles(from: url) + profileStatusMessage = "Imported \(count) profile\(count == 1 ? "" : "s")." + } catch { + profileStatusMessage = "Could not import profiles: \(error.localizedDescription)" + } + } + @ViewBuilder private func shortcutDisplayPicker( title: String, @@ -822,6 +960,16 @@ private struct MenuLayoutPane: View { .accessibilityLabel("Show Style Presets") } Divider().padding(.vertical, 6) + ModernRow( + title: "Show Profiles", + subtitle: "Show saved profiles as a quick switcher in the menu." + ) { + Toggle("", isOn: binding(\.showProfilesInMenu)) + .toggleStyle(.switch) + .labelsHidden() + .accessibilityLabel("Show Profiles") + } + Divider().padding(.vertical, 6) ModernRow( title: "Show Menu Bar Controls", subtitle: "Show menu bar text and click count controls in the menu." @@ -1119,6 +1267,7 @@ private enum SettingsPane: String, CaseIterable, Hashable { case events case style case shortcuts + case profiles case activity case menu @@ -1130,6 +1279,8 @@ private enum SettingsPane: String, CaseIterable, Hashable { return "Visual Style" case .shortcuts: return "Keyboard Shortcuts" + case .profiles: + return "Profiles" case .events: return "Event Visibility" case .activity: @@ -1147,6 +1298,8 @@ private enum SettingsPane: String, CaseIterable, Hashable { return "Size, intensity, duration, and color of click pulses." case .shortcuts: return "Set global shortcuts." + case .profiles: + return "Save and move reusable visual setups." case .events: return "Choose which interactions and shortcut overlays appear." case .activity: @@ -1164,6 +1317,8 @@ private enum SettingsPane: String, CaseIterable, Hashable { return "paintpalette" case .shortcuts: return "keyboard" + case .profiles: + return "rectangle.stack" case .events: return "cursorarrow.click.2" case .activity: diff --git a/Sources/ClickLight/ClickProfileStore.swift b/Sources/ClickLight/ClickProfileStore.swift new file mode 100644 index 0000000..bbba2d5 --- /dev/null +++ b/Sources/ClickLight/ClickProfileStore.swift @@ -0,0 +1,253 @@ +import AppKit +import Combine + +struct ClickSettingsProfile: Codable, Equatable, Identifiable { + let id: UUID + var name: String + var settings: ClickProfileSettings + var createdAt: Date + + init(id: UUID, name: String, settings: ClickProfileSettings, createdAt: Date) { + self.id = id + self.name = name + self.settings = settings + self.createdAt = createdAt + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case settings + case createdAt + case updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + settings = try container.decode(ClickProfileSettings.self, forKey: .settings) + createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) + ?? container.decodeIfPresent(Date.self, forKey: .updatedAt) + ?? Date() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(settings, forKey: .settings) + try container.encode(createdAt, forKey: .createdAt) + } +} + +struct ClickProfileSettings: Codable, Equatable { + var showPress: Bool + var showRelease: Bool + var showRightClick: Bool + var showMiddleClick: Bool + var showDrag: Bool + var showLaserPointer: Bool + var showLiveKeyboardShortcuts: Bool + var liveShortcutPosition: LiveShortcutPosition + var liveShortcutSize: LiveShortcutSize + var size: CGFloat + var intensity: CGFloat + var duration: TimeInterval + var colorPreset: ClickColorPreset + var customColorMode: CustomClickColorMode + var customColorRed: CGFloat + var customColorGreen: CGFloat + var customColorBlue: CGFloat + var customLeftColorRed: CGFloat + var customLeftColorGreen: CGFloat + var customLeftColorBlue: CGFloat + var customRightColorRed: CGFloat + var customRightColorGreen: CGFloat + var customRightColorBlue: CGFloat + var customMiddleColorRed: CGFloat + var customMiddleColorGreen: CGFloat + var customMiddleColorBlue: CGFloat + var customDragColorRed: CGFloat + var customDragColorGreen: CGFloat + var customDragColorBlue: CGFloat + var laserColorRed: CGFloat + var laserColorGreen: CGFloat + var laserColorBlue: CGFloat + var laserInnerColorRed: CGFloat + var laserInnerColorGreen: CGFloat + var laserInnerColorBlue: CGFloat + + init(settings: ClickSettings) { + self.showPress = settings.showPress + self.showRelease = settings.showRelease + self.showRightClick = settings.showRightClick + self.showMiddleClick = settings.showMiddleClick + self.showDrag = settings.showDrag + self.showLaserPointer = settings.showLaserPointer + self.showLiveKeyboardShortcuts = settings.showLiveKeyboardShortcuts + self.liveShortcutPosition = settings.liveShortcutPosition + self.liveShortcutSize = settings.liveShortcutSize + self.size = settings.size + self.intensity = settings.intensity + self.duration = settings.duration + self.colorPreset = settings.colorPreset + self.customColorMode = settings.customColorMode + self.customColorRed = settings.customColorRed + self.customColorGreen = settings.customColorGreen + self.customColorBlue = settings.customColorBlue + self.customLeftColorRed = settings.customLeftColorRed + self.customLeftColorGreen = settings.customLeftColorGreen + self.customLeftColorBlue = settings.customLeftColorBlue + self.customRightColorRed = settings.customRightColorRed + self.customRightColorGreen = settings.customRightColorGreen + self.customRightColorBlue = settings.customRightColorBlue + self.customMiddleColorRed = settings.customMiddleColorRed + self.customMiddleColorGreen = settings.customMiddleColorGreen + self.customMiddleColorBlue = settings.customMiddleColorBlue + self.customDragColorRed = settings.customDragColorRed + self.customDragColorGreen = settings.customDragColorGreen + self.customDragColorBlue = settings.customDragColorBlue + self.laserColorRed = settings.laserColorRed + self.laserColorGreen = settings.laserColorGreen + self.laserColorBlue = settings.laserColorBlue + self.laserInnerColorRed = settings.laserInnerColorRed + self.laserInnerColorGreen = settings.laserInnerColorGreen + self.laserInnerColorBlue = settings.laserInnerColorBlue + } + + func apply(to settings: inout ClickSettings) { + settings.showPress = showPress + settings.showRelease = showRelease + settings.showRightClick = showRightClick + settings.showMiddleClick = showMiddleClick + settings.showDrag = showDrag + settings.showLaserPointer = showLaserPointer + settings.showLiveKeyboardShortcuts = showLiveKeyboardShortcuts + settings.liveShortcutPosition = liveShortcutPosition + settings.liveShortcutSize = liveShortcutSize + settings.size = size + settings.intensity = intensity + settings.duration = duration + settings.colorPreset = colorPreset + settings.customColorMode = customColorMode + settings.customColorRed = customColorRed + settings.customColorGreen = customColorGreen + settings.customColorBlue = customColorBlue + settings.customLeftColorRed = customLeftColorRed + settings.customLeftColorGreen = customLeftColorGreen + settings.customLeftColorBlue = customLeftColorBlue + settings.customRightColorRed = customRightColorRed + settings.customRightColorGreen = customRightColorGreen + settings.customRightColorBlue = customRightColorBlue + settings.customMiddleColorRed = customMiddleColorRed + settings.customMiddleColorGreen = customMiddleColorGreen + settings.customMiddleColorBlue = customMiddleColorBlue + settings.customDragColorRed = customDragColorRed + settings.customDragColorGreen = customDragColorGreen + settings.customDragColorBlue = customDragColorBlue + settings.laserColorRed = laserColorRed + settings.laserColorGreen = laserColorGreen + settings.laserColorBlue = laserColorBlue + settings.laserInnerColorRed = laserInnerColorRed + settings.laserInnerColorGreen = laserInnerColorGreen + settings.laserInnerColorBlue = laserInnerColorBlue + } +} + +@MainActor +final class ClickProfileStore: ObservableObject { + private enum Key { + static let profiles = "settingsProfiles" + } + + @Published private(set) var profiles: [ClickSettingsProfile] + + private let defaults: UserDefaults + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.profiles = Self.loadProfiles(from: defaults) + } + + @discardableResult + func saveProfile(named rawName: String, from settings: ClickSettings) -> ClickSettingsProfile? { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return nil } + + let existing = profiles.first { $0.name.caseInsensitiveCompare(name) == .orderedSame } + let profile = ClickSettingsProfile( + id: existing?.id ?? UUID(), + name: name, + settings: ClickProfileSettings(settings: settings), + createdAt: existing?.createdAt ?? Date() + ) + + if let index = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[index] = profile + } else { + profiles.append(profile) + } + profiles.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + save() + return profile + } + + func delete(_ profile: ClickSettingsProfile) { + profiles.removeAll { $0.id == profile.id } + save() + } + + func exportProfiles(to url: URL) throws { + let exportEncoder = JSONEncoder() + exportEncoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let export = ClickProfileExport(version: 1, profiles: profiles) + try exportEncoder.encode(export).write(to: url, options: .atomic) + } + + @discardableResult + func importProfiles(from url: URL) throws -> Int { + let data = try Data(contentsOf: url) + let imported: [ClickSettingsProfile] + if let export = try? decoder.decode(ClickProfileExport.self, from: data) { + imported = export.profiles + } else { + imported = try decoder.decode([ClickSettingsProfile].self, from: data) + } + + for profile in imported { + if let index = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[index] = profile + } else if let index = profiles.firstIndex(where: { + $0.name.caseInsensitiveCompare(profile.name) == .orderedSame + }) { + profiles[index] = profile + } else { + profiles.append(profile) + } + } + profiles.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + save() + return imported.count + } + + private func save() { + guard let encoded = try? encoder.encode(profiles) else { return } + defaults.set(encoded, forKey: Key.profiles) + } + + private static func loadProfiles(from defaults: UserDefaults) -> [ClickSettingsProfile] { + guard let data = defaults.data(forKey: Key.profiles), + let saved = try? JSONDecoder().decode([ClickSettingsProfile].self, from: data) else { + return [] + } + return saved.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } +} + +private struct ClickProfileExport: Codable { + var version: Int + var profiles: [ClickSettingsProfile] +} diff --git a/Sources/ClickLight/SettingsStore.swift b/Sources/ClickLight/SettingsStore.swift index a921f1b..58dcdd0 100644 --- a/Sources/ClickLight/SettingsStore.swift +++ b/Sources/ClickLight/SettingsStore.swift @@ -13,6 +13,7 @@ struct ClickSettings: Equatable { var liveShortcutSize: LiveShortcutSize var showEventControlsInMenu: Bool var showStyleControlsInMenu: Bool + var showProfilesInMenu: Bool var showMenuBarControlsInMenu: Bool var showLaunchAtLoginInMenu: Bool var showMenuBarText: Bool @@ -138,6 +139,7 @@ struct ClickSettings: Equatable { liveShortcutSize: .medium, showEventControlsInMenu: true, showStyleControlsInMenu: true, + showProfilesInMenu: false, showMenuBarControlsInMenu: true, showLaunchAtLoginInMenu: true, showMenuBarText: false, @@ -287,7 +289,7 @@ struct ClickSettings: Equatable { } } -enum CustomClickColorMode: String, CaseIterable, Equatable { +enum CustomClickColorMode: String, CaseIterable, Codable, Equatable { case all case byClick @@ -301,7 +303,7 @@ enum CustomClickColorMode: String, CaseIterable, Equatable { } } -enum LiveShortcutPosition: String, CaseIterable, Equatable { +enum LiveShortcutPosition: String, CaseIterable, Codable, Equatable { case nearPointer case bottomCenter @@ -315,7 +317,7 @@ enum LiveShortcutPosition: String, CaseIterable, Equatable { } } -enum LiveShortcutSize: String, CaseIterable, Equatable { +enum LiveShortcutSize: String, CaseIterable, Codable, Equatable { case small case medium case large @@ -381,7 +383,7 @@ enum CustomClickColorTarget { case drag } -enum ClickColorPreset: String, CaseIterable, Equatable { +enum ClickColorPreset: String, CaseIterable, Codable, Equatable { case `default` case primary case blue @@ -484,6 +486,7 @@ final class SettingsStore { static let laserInnerColorBlue = "laserInnerColorBlue" static let showEventControlsInMenu = "showEventControlsInMenu" static let showStyleControlsInMenu = "showStyleControlsInMenu" + static let showProfilesInMenu = "showProfilesInMenu" static let showMenuBarControlsInMenu = "showMenuBarControlsInMenu" static let showLaunchAtLoginInMenu = "showLaunchAtLoginInMenu" static let toggleEnabledHotKeyCode = "toggleEnabledHotKeyCode" @@ -537,6 +540,7 @@ final class SettingsStore { liveShortcutSize: LiveShortcutSize(rawValue: defaults.string(forKey: Key.liveShortcutSize) ?? "") ?? .medium, showEventControlsInMenu: defaults.bool(forKey: Key.showEventControlsInMenu), showStyleControlsInMenu: defaults.bool(forKey: Key.showStyleControlsInMenu), + showProfilesInMenu: defaults.bool(forKey: Key.showProfilesInMenu), showMenuBarControlsInMenu: defaults.bool(forKey: Key.showMenuBarControlsInMenu), showLaunchAtLoginInMenu: defaults.bool(forKey: Key.showLaunchAtLoginInMenu), showMenuBarText: defaults.bool(forKey: Key.showMenuBarText), @@ -627,6 +631,7 @@ final class SettingsStore { defaults.set(newValue.liveShortcutSize.rawValue, forKey: Key.liveShortcutSize) defaults.set(newValue.showEventControlsInMenu, forKey: Key.showEventControlsInMenu) defaults.set(newValue.showStyleControlsInMenu, forKey: Key.showStyleControlsInMenu) + defaults.set(newValue.showProfilesInMenu, forKey: Key.showProfilesInMenu) defaults.set(newValue.showMenuBarControlsInMenu, forKey: Key.showMenuBarControlsInMenu) defaults.set(newValue.showLaunchAtLoginInMenu, forKey: Key.showLaunchAtLoginInMenu) defaults.set(newValue.showMenuBarText, forKey: Key.showMenuBarText) @@ -706,6 +711,7 @@ final class SettingsStore { Key.liveShortcutSize: defaults.liveShortcutSize.rawValue, Key.showEventControlsInMenu: defaults.showEventControlsInMenu, Key.showStyleControlsInMenu: defaults.showStyleControlsInMenu, + Key.showProfilesInMenu: defaults.showProfilesInMenu, Key.showMenuBarControlsInMenu: defaults.showMenuBarControlsInMenu, Key.showLaunchAtLoginInMenu: defaults.showLaunchAtLoginInMenu, Key.showMenuBarText: defaults.showMenuBarText, diff --git a/Sources/ClickLight/SettingsWindowController.swift b/Sources/ClickLight/SettingsWindowController.swift index e89eb3b..66d1c32 100644 --- a/Sources/ClickLight/SettingsWindowController.swift +++ b/Sources/ClickLight/SettingsWindowController.swift @@ -7,6 +7,7 @@ final class SettingsWindowController: NSWindowController { init( settingsStore: SettingsStore, + profileStore: ClickProfileStore, activityStore: ClickActivityStore, launchAtLogin: LaunchAtLoginManaging, permissions: PermissionController, @@ -21,7 +22,11 @@ final class SettingsWindowController: NSWindowController { self.viewModel = viewModel let hosting = NSHostingController( - rootView: ClickLightSettingsView(viewModel: viewModel, activityStore: activityStore) + rootView: ClickLightSettingsView( + viewModel: viewModel, + profileStore: profileStore, + activityStore: activityStore + ) ) let window = NSWindow(contentViewController: hosting) window.title = "ClickLight Settings" @@ -207,6 +212,12 @@ final class ClickLightSettingsViewModel: NSObject, ObservableObject { update { $0.applyRandomizedStyle() } } + func applyProfile(_ profile: ClickSettingsProfile) { + update { settings in + profile.settings.apply(to: &settings) + } + } + func applyCustomColor(_ color: NSColor) { guard let rgb = color.usingColorSpace(.deviceRGB) else { return } update { diff --git a/Sources/ClickLight/StatusController.swift b/Sources/ClickLight/StatusController.swift index 7374188..c0c537f 100644 --- a/Sources/ClickLight/StatusController.swift +++ b/Sources/ClickLight/StatusController.swift @@ -5,6 +5,7 @@ import Combine final class StatusController: NSObject { private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let settingsStore: SettingsStore + private let profileStore: ClickProfileStore private let activityStore: ClickActivityStore private let permissions: PermissionController private let launchAtLogin: LaunchAtLoginManaging @@ -18,6 +19,7 @@ final class StatusController: NSObject { init( settingsStore: SettingsStore, + profileStore: ClickProfileStore, activityStore: ClickActivityStore, permissions: PermissionController, launchAtLogin: LaunchAtLoginManaging, @@ -29,6 +31,7 @@ final class StatusController: NSObject { onMenuDidClose: @escaping () -> Void = {} ) { self.settingsStore = settingsStore + self.profileStore = profileStore self.activityStore = activityStore self.permissions = permissions self.launchAtLogin = launchAtLogin @@ -179,6 +182,11 @@ final class StatusController: NSObject { menu.addItem(.separator()) } + if settings.showProfilesInMenu { + menu.addItem(profilesSubmenu(settings: settings)) + menu.addItem(.separator()) + } + if settings.showMenuBarControlsInMenu { menu.addItem(toggleItem( title: "Show Menu Bar Text", @@ -335,6 +343,35 @@ final class StatusController: NSObject { return item } + private func profilesSubmenu(settings: ClickSettings) -> NSMenuItem { + let item = NSMenuItem(title: "Profiles", action: nil, keyEquivalent: "") + let menu = NSMenu() + let currentSettings = ClickProfileSettings(settings: settings) + + if profileStore.profiles.isEmpty { + let emptyItem = NSMenuItem(title: "No Profiles Saved", action: nil, keyEquivalent: "") + emptyItem.isEnabled = false + menu.addItem(emptyItem) + } else { + for profile in profileStore.profiles { + let child = NSMenuItem(title: profile.name, action: #selector(selectProfile(_:)), keyEquivalent: "") + child.target = self + child.representedObject = profile.id.uuidString + child.state = profile.settings == currentSettings ? .on : .off + child.isEnabled = profile.settings != currentSettings + menu.addItem(child) + } + } + + menu.addItem(.separator()) + let manageItem = NSMenuItem(title: "Manage Profiles...", action: #selector(openSettings), keyEquivalent: "") + manageItem.target = self + menu.addItem(manageItem) + + item.submenu = menu + return item + } + @objc private func toggleEnabled(_ sender: NSMenuItem) { dismissMenu(from: sender) settingsStore.update { $0.isEnabled.toggle() } @@ -420,6 +457,18 @@ final class StatusController: NSObject { settingsStore.update { $0.colorPreset = preset } } + @objc private func selectProfile(_ sender: NSMenuItem) { + guard + let rawValue = sender.representedObject as? String, + let profileID = UUID(uuidString: rawValue), + let profile = profileStore.profiles.first(where: { $0.id == profileID }) + else { return } + dismissMenu(from: sender) + settingsStore.update { settings in + profile.settings.apply(to: &settings) + } + } + @objc private func openAccessibilitySettings() { permissions.requestAccessibilityIfNeeded() permissions.openPrivacySettings()