diff --git a/App/Sources/Core/Controllers/ApplicationTriggerController.swift b/App/Sources/Core/Controllers/ApplicationTriggerController.swift index b04e287a..c2942f37 100644 --- a/App/Sources/Core/Controllers/ApplicationTriggerController.swift +++ b/App/Sources/Core/Controllers/ApplicationTriggerController.swift @@ -1,5 +1,6 @@ import Combine import Cocoa +import MachPort final class ApplicationTriggerController { private let commandRunner: CommandRunning @@ -94,12 +95,15 @@ final class ApplicationTriggerController { private func runCommands(in workflow: Workflow) { let commands = workflow.commands.filter(\.isEnabled) + guard let machPortEvent = MachPortEvent.empty() else { return } switch workflow.execution { case .concurrent: commandRunner.concurrentRun( commands, checkCancellation: true, resolveUserEnvironment: workflow.resolveUserEnvironment(), + shortcut: .empty(), + machPortEvent: machPortEvent, repeatingEvent: false ) case .serial: @@ -107,6 +111,8 @@ final class ApplicationTriggerController { commands, checkCancellation: true, resolveUserEnvironment: workflow.resolveUserEnvironment(), + shortcut: .empty(), + machPortEvent: machPortEvent, repeatingEvent: false ) } diff --git a/App/Sources/Core/Core.swift b/App/Sources/Core/Core.swift index 523970ab..e5496462 100644 --- a/App/Sources/Core/Core.swift +++ b/App/Sources/Core/Core.swift @@ -72,13 +72,19 @@ final class Core { recorderStore: recorderStore, shortcutStore: shortcutStore, scriptCommandRunner: scriptCommandRunner) + lazy private(set) var macroCoordinator = MacroCoordinator() lazy private(set) var groupStore = GroupStore() lazy private(set) var keyCodeStore = KeyCodesStore(InputSourceController()) + lazy private(set) var workflowRunner = WorkflowRunner(commandRunner: commandRunner, + store: keyCodeStore, notifications: notifications) + lazy private(set) var notifications = MachPortUINotifications(keyboardShortcutsController: keyboardShortcutsController) lazy private(set) var machPortCoordinator = MachPortCoordinator(store: keyboardCommandRunner.store, - commandRunner: commandRunner, keyboardCommandRunner: keyboardCommandRunner, keyboardShortcutsController: keyboardShortcutsController, - mode: .intercept) + macroCoordinator: macroCoordinator, + mode: .intercept, + notifications: notifications, + workflowRunner: workflowRunner) lazy private(set) var engine = KeyboardCowboyEngine( contentStore, commandRunner: commandRunner, @@ -97,7 +103,7 @@ final class Core { // MARK: - Runners lazy private(set) var commandRunner = CommandRunner( applicationStore: ApplicationStore.shared, - builtInCommandRunner: BuiltInCommandRunner(configurationStore: configurationStore), + builtInCommandRunner: BuiltInCommandRunner(configurationStore: configurationStore, macroRunner: macroRunner), scriptCommandRunner: scriptCommandRunner, keyboardCommandRunner: keyboardCommandRunner, uiElementCommandRunner: uiElementCommandRunner @@ -105,6 +111,7 @@ final class Core { lazy private(set) var keyboardCommandRunner = KeyboardCommandRunner(store: keyCodeStore) lazy private(set) var uiElementCommandRunner = UIElementCommandRunner() lazy private(set) var scriptCommandRunner = ScriptCommandRunner(workspace: .shared) + lazy private(set) var macroRunner = MacroRunner(coordinator: macroCoordinator) // MARK: - Controllers diff --git a/App/Sources/Core/KeyboardCowboy.swift b/App/Sources/Core/KeyboardCowboy.swift index 37b4a778..1d3c09e5 100644 --- a/App/Sources/Core/KeyboardCowboy.swift +++ b/App/Sources/Core/KeyboardCowboy.swift @@ -61,6 +61,7 @@ struct KeyboardCowboy: App { PermissionsWindow() PermissionsScene(onAction: handlePermissionAction(_:)) + ReleaseNotesScene() NewCommandWindow(contentStore: core.contentStore, uiElementCaptureStore: core.uiElementCaptureStore, @@ -91,6 +92,11 @@ struct KeyboardCowboy: App { handleAppScene(.permissions) return } + + if AppStorageContainer.shared.releaseNotes < KeyboardCowboy.marektingVersion { + openWindow(id: KeyboardCowboy.releaseNotesWindowIdentifier) + } + } case .openMainWindow: handleAppScene(.mainWindow) @@ -133,3 +139,9 @@ struct KeyboardCowboy: App { } } } + +private extension String { + static func < (lhs: String, rhs: String) -> Bool { + return lhs.compare(rhs, options: .numeric) == .orderedAscending + } +} diff --git a/App/Sources/Core/Models/Commands/BuildInCommand.swift b/App/Sources/Core/Models/Commands/BuildInCommand.swift index 6814fd14..29a06e1e 100644 --- a/App/Sources/Core/Models/Commands/BuildInCommand.swift +++ b/App/Sources/Core/Models/Commands/BuildInCommand.swift @@ -11,34 +11,44 @@ struct BuiltInCommand: MetaDataProviding { case toggle } + case macro(MacroAction) case userMode(UserMode, Action) var id: String { switch self { - case .userMode(let id, let action): + case .macro(let macro): + return macro.id + case .userMode(let id, let action): return switch action { - case .enable: "enable-\(id)" - case .disable: "disable-\(id)" - case .toggle: "toggle-\(id)" + case .enable: "enable-\(id)" + case .disable: "disable-\(id)" + case .toggle: "toggle-\(id)" } } } var userModeId: UserMode.ID { switch self { + case .macro(let action): + return action.id case .userMode(let model, _): - return model.id + return model.id } } public var displayValue: String { switch self { + case .macro(let action): + switch action.kind { + case .remove: "Remove Macro" + case .record: "Record Macro" + } case .userMode(_, let action): - return switch action { - case .enable: "Enable User Mode" - case .disable: "Disable User Mode" - case .toggle: "Toggle User Mode" - } + switch action { + case .enable: "Enable User Mode" + case .disable: "Disable User Mode" + case .toggle: "Toggle User Mode" + } } } } diff --git a/App/Sources/Core/Models/KeyboardCowboyMode.swift b/App/Sources/Core/Models/KeyboardCowboyMode.swift index fc629c1b..c3d1c3be 100644 --- a/App/Sources/Core/Models/KeyboardCowboyMode.swift +++ b/App/Sources/Core/Models/KeyboardCowboyMode.swift @@ -1,6 +1,7 @@ enum KeyboardCowboyMode { case intercept case recordKeystroke + case recordMacro case captureUIElement case disabled } diff --git a/App/Sources/Core/Models/MacroAction.swift b/App/Sources/Core/Models/MacroAction.swift new file mode 100644 index 00000000..a68d6d02 --- /dev/null +++ b/App/Sources/Core/Models/MacroAction.swift @@ -0,0 +1,24 @@ +import Foundation + +struct MacroAction: Identifiable, Codable, Hashable, Sendable { + let id: String + let kind: Kind + + enum Kind: Codable { + case record + case remove + } + + init(id: String, kind: Kind) { + self.id = id + self.kind = kind + } + + init(_ kind: Kind, id: String = UUID().uuidString) { + self.id = id + self.kind = kind + } + + static var record: MacroAction { MacroAction(.record) } + static var remove: MacroAction { MacroAction(.remove) } +} diff --git a/App/Sources/Core/Runners/BuiltInCommandRunner.swift b/App/Sources/Core/Runners/BuiltInCommandRunner.swift index 6a2a13a4..c41244cb 100644 --- a/App/Sources/Core/Runners/BuiltInCommandRunner.swift +++ b/App/Sources/Core/Runners/BuiltInCommandRunner.swift @@ -1,18 +1,26 @@ import Foundation +import MachPort final class BuiltInCommandRunner { let configurationStore: ConfigurationStore + let macroRunner: MacroRunner - init(configurationStore: ConfigurationStore) { + init(configurationStore: ConfigurationStore, + macroRunner: MacroRunner) { self.configurationStore = configurationStore + self.macroRunner = macroRunner } - func run(_ command: BuiltInCommand) async throws -> String { + func run(_ command: BuiltInCommand, + shortcut: KeyShortcut, + machPortEvent: MachPortEvent) async throws -> String { return switch command.kind { - case .userMode(let model, let action): try await UserModesRunner( - configurationStore: configurationStore - ) - .run(model, builtInCommand: command, action: action) + case .macro(let action): + await macroRunner + .run(action, shortcut: shortcut, machPortEvent: machPortEvent) + case .userMode(let model, let action): + try await UserModesRunner(configurationStore: configurationStore) + .run(model, builtInCommand: command, action: action) } } } diff --git a/App/Sources/Core/Runners/CommandRunner.swift b/App/Sources/Core/Runners/CommandRunner.swift index 6ffc2bde..f78f0285 100644 --- a/App/Sources/Core/Runners/CommandRunner.swift +++ b/App/Sources/Core/Runners/CommandRunner.swift @@ -6,9 +6,12 @@ import MachPort protocol CommandRunning { func serialRun(_ commands: [Command], checkCancellation: Bool, - resolveUserEnvironment: Bool, repeatingEvent: Bool) + resolveUserEnvironment: Bool, shortcut: KeyShortcut, machPortEvent: MachPortEvent, + repeatingEvent: Bool) func concurrentRun(_ commands: [Command], checkCancellation: Bool, - resolveUserEnvironment: Bool, repeatingEvent: Bool) + resolveUserEnvironment: Bool, + shortcut: KeyShortcut, machPortEvent: MachPortEvent, + repeatingEvent: Bool) } final class CommandRunner: CommandRunning, @unchecked Sendable { @@ -110,9 +113,9 @@ final class CommandRunner: CommandRunning, @unchecked Sendable { } } - func serialRun(_ commands: [Command], - checkCancellation: Bool, - resolveUserEnvironment: Bool, + func serialRun(_ commands: [Command], checkCancellation: Bool, + resolveUserEnvironment: Bool, + shortcut: KeyShortcut, machPortEvent: MachPortEvent, repeatingEvent: Bool) { let originalPasteboardContents: String? = commands.shouldRestorePasteboard ? NSPasteboard.general.string(forType: .string) @@ -133,7 +136,8 @@ final class CommandRunner: CommandRunning, @unchecked Sendable { for command in commands { if checkCancellation { try Task.checkCancellation() } do { - try await self.run(command, snapshot: snapshot, repeatingEvent: repeatingEvent) + try await self.run(command, snapshot: snapshot, shortcut: shortcut, + machPortEvent: machPortEvent, repeatingEvent: repeatingEvent) } catch { } if let delay = command.delay { try await Task.sleep(for: .milliseconds(delay)) @@ -152,7 +156,9 @@ final class CommandRunner: CommandRunning, @unchecked Sendable { } func concurrentRun(_ commands: [Command], checkCancellation: Bool, - resolveUserEnvironment: Bool, repeatingEvent: Bool) { + resolveUserEnvironment: Bool, + shortcut: KeyShortcut, machPortEvent: MachPortEvent, + repeatingEvent: Bool) { let originalPasteboardContents: String? = commands.shouldRestorePasteboard ? NSPasteboard.general.string(forType: .string) : nil @@ -161,8 +167,7 @@ final class CommandRunner: CommandRunning, @unchecked Sendable { guard let self else { return } let shouldDismissMissionControl = commands.contains(where: { switch $0 { - case .builtIn: false - default: true + case .builtIn: false default: true } }) @@ -172,7 +177,8 @@ final class CommandRunner: CommandRunning, @unchecked Sendable { for command in commands { do { if checkCancellation { try Task.checkCancellation() } - try await self.run(command, snapshot: snapshot, repeatingEvent: repeatingEvent) + try await self.run(command, snapshot: snapshot, shortcut: shortcut, + machPortEvent: machPortEvent, repeatingEvent: repeatingEvent) } catch { } } @@ -186,7 +192,10 @@ final class CommandRunner: CommandRunning, @unchecked Sendable { } } - func run(_ command: Command, snapshot: UserSpace.Snapshot, repeatingEvent: Bool) async throws { + func run(_ command: Command, snapshot: UserSpace.Snapshot, + shortcut: KeyShortcut, + machPortEvent: MachPortEvent, + repeatingEvent: Bool) async throws { do { let id = UUID().uuidString if command.notification { @@ -200,7 +209,11 @@ final class CommandRunner: CommandRunning, @unchecked Sendable { try await runners.application.run(applicationCommand) output = command.name case .builtIn(let builtInCommand): - output = try await runners.builtIn.run(builtInCommand) + output = try await runners.builtIn.run( + builtInCommand, + shortcut: shortcut, + machPortEvent: machPortEvent + ) case .keyboard(let keyboardCommand): try runners.keyboard.run(keyboardCommand.keyboardShortcuts, type: .keyDown, diff --git a/App/Sources/Core/Runners/MachPortCoordinator.swift b/App/Sources/Core/Runners/MachPortCoordinator.swift index 3b274350..df797dec 100644 --- a/App/Sources/Core/Runners/MachPortCoordinator.swift +++ b/App/Sources/Core/Runners/MachPortCoordinator.swift @@ -38,24 +38,28 @@ final class MachPortCoordinator { private var workItem: DispatchWorkItem? private var capsLockDown: Bool = false - private let commandRunner: CommandRunner + private let macroCoordinator: MacroCoordinator private let keyboardCommandRunner: KeyboardCommandRunner private let keyboardShortcutsController: KeyboardShortcutsController private let notifications: MachPortUINotifications private let store: KeyCodesStore + private let workflowRunner: WorkflowRunner internal init(store: KeyCodesStore, - commandRunner: CommandRunner, keyboardCommandRunner: KeyboardCommandRunner, keyboardShortcutsController: KeyboardShortcutsController, - mode: KeyboardCowboyMode) { - self.commandRunner = commandRunner + macroCoordinator: MacroCoordinator, + mode: KeyboardCowboyMode, + notifications: MachPortUINotifications, + workflowRunner: WorkflowRunner) { + self.macroCoordinator = macroCoordinator self.store = store self.keyboardShortcutsController = keyboardShortcutsController self.keyboardCommandRunner = keyboardCommandRunner - self.notifications = MachPortUINotifications(keyboardShortcutsController: keyboardShortcutsController) + self.notifications = notifications self.mode = mode self.specialKeys = Array(store.specialKeys().keys) + self.workflowRunner = workflowRunner } func captureUIElement() { @@ -78,58 +82,57 @@ final class MachPortCoordinator { func receiveEvent(_ machPortEvent: MachPortEvent) { switch mode { - case .disabled: break - case .captureUIElement: - self.event = machPortEvent - case .intercept: + case .disabled: return + case .captureUIElement: break + case .intercept, .recordMacro: guard machPortEvent.type != .leftMouseUp && machPortEvent.type != .leftMouseDown && machPortEvent.type != .leftMouseDragged else { return } - intercept(machPortEvent) - self.event = machPortEvent + intercept(machPortEvent, runningMacro: false) case .recordKeystroke: record(machPortEvent) - self.event = machPortEvent } + + self.event = machPortEvent } func receiveFlagsChanged(_ machPortEvent: MachPortEvent) { let flags = machPortEvent.event.flags - self.workItem?.cancel() - self.workItem = nil - self.flagsChanged = flags - self.capsLockDown = machPortEvent.keyCode == kVK_CapsLock - self.repeatingResult = nil - self.repeatingMatch = nil - self.repeatingKeyCode = -1 + workItem?.cancel() + workItem = nil + flagsChanged = flags + capsLockDown = machPortEvent.keyCode == kVK_CapsLock + repeatingResult = nil + repeatingMatch = nil + repeatingKeyCode = -1 } // MARK: - Private methods - private func intercept(_ machPortEvent: MachPortEvent, tryGlobals: Bool = false) { + private func intercept(_ machPortEvent: MachPortEvent, tryGlobals: Bool = false, runningMacro: Bool) { if launchArguments.isEnabled(.disableMachPorts) { return } let isRepeatingEvent: Bool = machPortEvent.event.getIntegerValueField(.keyboardEventAutorepeat) == 1 switch machPortEvent.type { - case .keyDown: - if previousPartialMatch.rawValue != Self.defaultPartialMatch.rawValue, - machPortEvent.keyCode == kVK_Escape { - if machPortEvent.event.flags == CGEventFlags.maskNonCoalesced { - machPortEvent.result = nil - reset() - return + case .keyDown: + if previousPartialMatch.rawValue != Self.defaultPartialMatch.rawValue, + machPortEvent.keyCode == kVK_Escape { + if machPortEvent.event.flags == CGEventFlags.maskNonCoalesced { + machPortEvent.result = nil + reset() + return + } } - } - case .keyUp: - workItem?.cancel() - workItem = nil - repeatingResult = nil - repeatingMatch = nil - default: - return + case .keyUp: + workItem?.cancel() + workItem = nil + repeatingResult = nil + repeatingMatch = nil + default: + return } // If the event is repeating and there is an earlier result, @@ -138,48 +141,59 @@ final class MachPortCoordinator { machPortEvent.result = nil repeatingResult(machPortEvent, true) return - // If the event is repeating and there is no earlier result, - // simply opt-out because we don't want to lookup the same - // keyboard shortcut over and over again. + // If the event is repeating and there is no earlier result, + // simply opt-out because we don't want to lookup the same + // keyboard shortcut over and over again. } else if isRepeatingEvent, repeatingMatch == false { return - // Reset the repeating result and match if the event is not repeating. + // Reset the repeating result and match if the event is not repeating. } else { repeatingResult = nil repeatingMatch = nil repeatingKeyCode = -1 } - guard let displayValue = store.displayValue(for: Int(machPortEvent.keyCode)) else { + guard let shortcut = MachPortKeyboardShortcut(machPortEvent, specialKeys: specialKeys, store: store) else { return } - let modifiers = VirtualModifierKey.fromCGEvent(machPortEvent.event, specialKeys: specialKeys) - .compactMap({ ModifierKey(rawValue: $0.rawValue) }) + var keyboardShortcut: KeyShortcut = shortcut.original + + if machPortEvent.type == .keyDown { + // If there is a match, then run the workflow + let readyToRunMacro = mode == .intercept && macroCoordinator.state == .idle + if readyToRunMacro, let macro = macroCoordinator.match(shortcut) { + for element in macro { + switch element { + case .event(let machPortEvent): + try? machPort?.post(Int(machPortEvent.keyCode), type: .keyDown, flags: machPortEvent.event.flags) + try? machPort?.post(Int(machPortEvent.keyCode), type: .keyUp, flags: machPortEvent.event.flags) + case .workflow(let workflow): + workflowRunner.run(workflow, for: keyboardShortcut, + executionOverride: .serial, + machPortEvent: machPortEvent, repeatingEvent: false) + } + } - let keyboardShortcut = KeyShortcut( - id: UUID().uuidString, - key: displayValue, - lhs: machPortEvent.lhs, - modifiers: modifiers - ) + machPortEvent.result = nil + return + } else if macroCoordinator.state == .removing { + macroCoordinator.remove(shortcut, machPortEvent: machPortEvent) + return + } + } + + let bundleIdentifier = UserSpace.shared.frontMostApplication.bundleIdentifier // Found a match let userModes = UserSpace.shared.userModes.filter(\.isEnabled) - var result = keyboardShortcutsController.lookup( - keyboardShortcut, - bundleIdentifier: UserSpace.shared.frontMostApplication.bundleIdentifier, - userModes: userModes, - partialMatch: previousPartialMatch + var result = keyboardShortcutsController.lookup(shortcut.original, bundleIdentifier: bundleIdentifier, + userModes: userModes, partialMatch: previousPartialMatch ) if result == nil { - result = keyboardShortcutsController.lookup( - KeyShortcut(key: displayValue.uppercased(), lhs: machPortEvent.lhs, modifiers: modifiers), - bundleIdentifier: UserSpace.shared.frontMostApplication.bundleIdentifier, - userModes: userModes, - partialMatch: previousPartialMatch - ) - + result = keyboardShortcutsController.lookup(shortcut.uppercase, bundleIdentifier: bundleIdentifier, + userModes: userModes, partialMatch: previousPartialMatch) + keyboardShortcut = shortcut.uppercase // Workaround for the mismatch that can occur when the user tries to type // a sequence that involves conflicting positions for the modifier keys. // When done in quick succession, the `flagsChanged` event will report @@ -187,25 +201,26 @@ final class MachPortCoordinator { // not always accurate. This workaround disables left-hand-side conditions // for workflows that use keyboard shortcut sequences. if previousPartialMatch.rawValue != Self.defaultPartialMatch.rawValue && result == nil { - result = keyboardShortcutsController.lookup( - KeyShortcut(key: displayValue, lhs: false, modifiers: modifiers), - bundleIdentifier: UserSpace.shared.frontMostApplication.bundleIdentifier, - userModes: userModes, - partialMatch: previousPartialMatch - ) + result = keyboardShortcutsController.lookup(shortcut.lhsAgnostic, bundleIdentifier: bundleIdentifier, + userModes: userModes, partialMatch: previousPartialMatch) + keyboardShortcut = shortcut.lhsAgnostic } } process(result, machPortEvent: machPortEvent, + shortcut: shortcut, isRepeatingEvent: isRepeatingEvent, - tryGlobals: tryGlobals) + tryGlobals: tryGlobals, + runningMacro: runningMacro) } private func process(_ result: KeyboardShortcutResult?, machPortEvent: MachPortEvent, + shortcut: MachPortKeyboardShortcut, isRepeatingEvent: Bool, - tryGlobals: Bool) { + tryGlobals: Bool, + runningMacro: Bool) { switch result { case .partialMatch(let partialMatch): if let workflow = partialMatch.workflow, @@ -226,11 +241,12 @@ final class MachPortCoordinator { machPortEvent.result = nil } - let enabledWorkflows = workflow.commands.filter(\.isEnabled) + let enabledCommands = workflow.commands.filter(\.isEnabled) let execution: (MachPortEvent, Bool) -> Void - if enabledWorkflows.count == 1, - case .keyboard(let command) = enabledWorkflows.first { + // Handle keyboard commands early to avoid cancelling previous keyboard invocations. + if enabledCommands.count == 1, + case .keyboard(let command) = enabledCommands.first { if !isRepeatingEvent && machPortEvent.event.type == .keyDown { notifications.notifyKeyboardCommand(workflow, command: command) } @@ -241,19 +257,27 @@ final class MachPortCoordinator { originalEvent: machPortEvent.event, with: machPortEvent.eventSource) } + if machPortEvent.type == .keyDown, macroCoordinator.state == .recording { + macroCoordinator.record(shortcut, kind: .workflow(workflow), machPortEvent: machPortEvent) + } execution(machPortEvent, isRepeatingEvent) repeatingResult = execution repeatingKeyCode = machPortEvent.keyCode previousPartialMatch = Self.defaultPartialMatch + } else if workflow.commands.isValidForRepeat { guard machPortEvent.type == .keyDown else { return } - execution = { [weak self] machPortEvent, repeatingEvent in - self?.run(workflow, repeatingEvent: repeatingEvent) + if macroCoordinator.state == .recording { + macroCoordinator.record(shortcut, kind: .workflow(workflow), machPortEvent: machPortEvent) + } + execution = { [workflowRunner] machPortEvent, repeatingEvent in + workflowRunner.run(workflow, for: shortcut.original, machPortEvent: machPortEvent, repeatingEvent: repeatingEvent) } execution(machPortEvent, isRepeatingEvent) repeatingResult = execution repeatingKeyCode = machPortEvent.keyCode previousPartialMatch = Self.defaultPartialMatch + } else if workflow.commands.allSatisfy({ if case .systemCommand = $0 { return true } else { return false } }) { @@ -270,18 +294,28 @@ final class MachPortCoordinator { } } + if macroCoordinator.state == .recording && machPortEvent.type == .keyDown { + macroCoordinator.record(shortcut, kind: .workflow(workflow), machPortEvent: machPortEvent) + } + if let delay = shouldSchedule(workflow) { - workItem = schedule(workflow, after: delay) + workItem = schedule(workflow, for: shortcut.original, machPortEvent: machPortEvent, after: delay) } else { - run(workflow, repeatingEvent: false) + workflowRunner.run(workflow, for: shortcut.original, machPortEvent: machPortEvent, repeatingEvent: false) } + } else if machPortEvent.type == .keyDown, !isRepeatingEvent { + if macroCoordinator.state == .recording { + macroCoordinator.record(shortcut, kind: .workflow(workflow), machPortEvent: machPortEvent) + } + if let delay = shouldSchedule(workflow) { - workItem = schedule(workflow, after: delay) + workItem = schedule(workflow, for: shortcut.original, machPortEvent: machPortEvent, after: delay) } else { - run(workflow, repeatingEvent: false) + workflowRunner.run(workflow, for: shortcut.original, machPortEvent: machPortEvent, repeatingEvent: false) } + previousPartialMatch = Self.defaultPartialMatch } case .none: @@ -296,8 +330,12 @@ final class MachPortCoordinator { // machPortEvent.event.flags = newFlags if !tryGlobals { - intercept(machPortEvent, tryGlobals: true) + intercept(machPortEvent, tryGlobals: true, runningMacro: runningMacro) repeatingMatch = false + } else { + if macroCoordinator.state == .recording { + macroCoordinator.record(shortcut, kind: .event(machPortEvent), machPortEvent: machPortEvent) + } } } } @@ -305,8 +343,8 @@ final class MachPortCoordinator { private func record(_ machPortEvent: MachPortEvent) { machPortEvent.result = nil - self.mode = .intercept - self.recording = validate(machPortEvent, allowAllKeys: true) + mode = .intercept + recording = validate(machPortEvent, allowAllKeys: true) } private func validate(_ machPortEvent: MachPortEvent, allowAllKeys: Bool = false) -> KeyShortcutRecording { @@ -317,8 +355,7 @@ final class MachPortCoordinator { } let virtualModifiers = VirtualModifierKey - .fromCGEvent(machPortEvent.event, - specialKeys: Array(store.specialKeys().keys)) + .fromCGEvent(machPortEvent.event, specialKeys: Array(store.specialKeys().keys)) let modifiers = virtualModifiers .compactMap({ ModifierKey(rawValue: $0.rawValue) }) let keyboardShortcut = KeyShortcut( @@ -346,50 +383,19 @@ final class MachPortCoordinator { } } - private func run(_ workflow: Workflow, repeatingEvent: Bool) { - notifications.notifyRunningWorkflow(workflow) - let commands = workflow.commands.filter(\.isEnabled) - - /// Determines whether the command runner should check for cancellation. - /// If the workflow is triggered by a keyboard shortcut that is a passthrough and consists of only one shortcut, - /// and that shortcut is the escape key, then cancellation checking is disabled. - var checkCancellation: Bool = true - if let trigger = workflow.trigger, - case .keyboardShortcuts(let keyboardShortcutTrigger) = trigger, - keyboardShortcutTrigger.passthrough, - keyboardShortcutTrigger.shortcuts.count == 1 { - let shortcut = keyboardShortcutTrigger.shortcuts[0] - let displayValue = store.displayValue(for: kVK_Escape) - if shortcut.key == displayValue { - checkCancellation = false - } - } - - let resolveUserEnvironment = workflow.resolveUserEnvironment() - switch workflow.execution { - case .concurrent: - commandRunner.concurrentRun(commands, checkCancellation: checkCancellation, - resolveUserEnvironment: resolveUserEnvironment, - repeatingEvent: repeatingEvent) - case .serial: - commandRunner.serialRun(commands, checkCancellation: checkCancellation, - resolveUserEnvironment: resolveUserEnvironment, - repeatingEvent: repeatingEvent) - } - } - private func reset(_ function: StaticString = #function, line: Int = #line) { previousPartialMatch = Self.defaultPartialMatch notifications.reset() } - private func schedule(_ workflow: Workflow, after duration: Double) -> DispatchWorkItem { + private func schedule(_ workflow: Workflow, for shortcut: KeyShortcut, + machPortEvent: MachPortEvent, after duration: Double) -> DispatchWorkItem { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } guard self.workItem?.isCancelled != true else { return } - self.run(workflow, repeatingEvent: false) + workflowRunner.run(workflow, for: shortcut, machPortEvent: machPortEvent, repeatingEvent: false) } DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem) return workItem @@ -423,3 +429,24 @@ private extension Collection where Element == Command { } } } + +struct MachPortKeyboardShortcut: Hashable, Identifiable { + var id: String { original.key + original.modifersDisplayValue + ":" + (original.lhs ? "true" : "false") } + + let original: KeyShortcut + let uppercase: KeyShortcut + let lhsAgnostic: KeyShortcut + + init?(_ machPortEvent: MachPortEvent, specialKeys: [Int], store: KeyCodesStore) { + guard let displayValue = store.displayValue(for: Int(machPortEvent.keyCode)) else { + return nil + } + + let modifiers = VirtualModifierKey.fromCGEvent(machPortEvent.event, specialKeys: specialKeys) + .compactMap({ ModifierKey(rawValue: $0.rawValue) }) + + self.original = KeyShortcut(id: UUID().uuidString, key: displayValue, lhs: machPortEvent.lhs, modifiers: modifiers) + self.uppercase = KeyShortcut(key: displayValue.uppercased(), lhs: machPortEvent.lhs, modifiers: modifiers) + self.lhsAgnostic = KeyShortcut(key: displayValue, lhs: false, modifiers: modifiers) + } +} diff --git a/App/Sources/Core/Runners/MacroCoordinator.swift b/App/Sources/Core/Runners/MacroCoordinator.swift new file mode 100644 index 00000000..09fadcb6 --- /dev/null +++ b/App/Sources/Core/Runners/MacroCoordinator.swift @@ -0,0 +1,120 @@ +import Foundation +import MachPort + +enum MacroKind { + case event(_ machPortEvent: MachPortEvent) + case workflow(_ workflow: Workflow) +} + +final class MacroCoordinator { + enum State { + case recording + case removing + case idle + } + + var state: State = .idle { + willSet { + if newValue == .idle { + newMacroKey = nil + recordingKey = nil + } + } + } + var machPort: MachPortEventController? + + private(set) var newMacroKey: MachPortKeyboardShortcut? + private(set) var recordingKey: MacroKey? + + private var currentBundleIdentifier: String = Bundle.main.bundleIdentifier! + private var macros = [MacroKey: [MacroKind]]() + + private let bezelId = "com.apple.zenangst.Keyboard-Cowboy.macros" + private let userSpace = UserSpace.shared + + func match(_ shortcut: MachPortKeyboardShortcut) -> [MacroKind]? { + let macroKey = MacroKey(bundleIdentifier: userSpace.frontMostApplication.bundleIdentifier, + machPortKeyId: shortcut.id) + if let macro = macros[macroKey] { + Task { @MainActor [bezelId] in + BezelNotificationController.shared.post(.init(id: bezelId, text: "Running Macro for \(shortcut.original.modifersDisplayValue) \(shortcut.uppercase.key)")) + } + return macro + } + + return nil + } + + func record(_ shortcut: MachPortKeyboardShortcut, kind: MacroKind, machPortEvent: MachPortEvent) { + guard state == .recording else { return } + + if case .workflow(let workflow) = kind { + // Should never record macro related commands. + let isValid = workflow.commands.contains(where: { + switch $0 { + case .builtIn(let command): + switch command.kind { + case .macro: false + default: true + } + default: + true + } + }) + + if !isValid { return } + } + + if let recordingKey, let newMacroKey { + if shortcut.id == newMacroKey.id { + machPortEvent.result = nil + state = .idle + Task { @MainActor [bezelId] in + BezelNotificationController.shared.post(.init(id: bezelId, text: "Recorded Macro for \(shortcut.original.modifersDisplayValue) \(shortcut.uppercase.key)")) + } + return + } + + if macros[recordingKey] == nil { + macros[recordingKey] = [kind] + } else { + macros[recordingKey]?.append(kind) + } + } else { + let recordingKey = MacroKey(bundleIdentifier: userSpace.frontMostApplication.bundleIdentifier, + machPortKeyId: shortcut.id) + macros[recordingKey] = nil + self.newMacroKey = shortcut + self.recordingKey = recordingKey + Task { @MainActor [bezelId] in + BezelNotificationController.shared.post(.init(id: bezelId, text: "Recording Macro for \(shortcut.original.modifersDisplayValue) \(shortcut.uppercase.key)")) + } + machPortEvent.result = nil + } + } + + func remove(_ shortcut: MachPortKeyboardShortcut, machPortEvent: MachPortEvent) { + let macroKey = MacroKey(bundleIdentifier: userSpace.frontMostApplication.bundleIdentifier, + machPortKeyId: shortcut.id) + if macros[macroKey] != nil { + macros[macroKey] = nil + Task { @MainActor [bezelId] in + BezelNotificationController.shared.post(.init(id: bezelId, text: "Removed Macro for \(shortcut.original.modifersDisplayValue) \(shortcut.uppercase.key)")) + } + } + + state = .idle + machPortEvent.result = nil + } +} + +struct MacroKey: Hashable { + let bundleIdentifier: String + let machPortKeyId: String +} + +private extension KeyShortcut { + var machPortKeyId: String { + key + modifersDisplayValue + ":" + (lhs ? "true" : "false") + } +} diff --git a/App/Sources/Core/Runners/MacroRunner.swift b/App/Sources/Core/Runners/MacroRunner.swift new file mode 100644 index 00000000..6a5e97fc --- /dev/null +++ b/App/Sources/Core/Runners/MacroRunner.swift @@ -0,0 +1,36 @@ +import Foundation +import MachPort + +final class MacroRunner { + private let coordinator: MacroCoordinator + private let bezelId = "com.zenangst.Keyboard-Cowboy.MacroRunner" + + init(coordinator: MacroCoordinator) { + self.coordinator = coordinator + } + + func run(_ macroAction: MacroAction, + shortcut: KeyShortcut, + machPortEvent: MachPortEvent) async -> String { + let output: String + switch macroAction.kind { + case .record: + if let newMacroKey = coordinator.newMacroKey { + coordinator.state = .idle + output = "Recorded Macro for \(newMacroKey.original.modifersDisplayValue) \(newMacroKey.uppercase.key)" + } else { + coordinator.state = .recording + output = "Choose Macro key..." + } + case .remove: + coordinator.state = .removing + output = "Remove Macro key..." + } + + Task { @MainActor [bezelId] in + BezelNotificationController.shared.post(.init(id: bezelId, text: output)) + } + + return output + } +} diff --git a/App/Sources/Core/Runners/WorkflowRunner.swift b/App/Sources/Core/Runners/WorkflowRunner.swift new file mode 100644 index 00000000..d7b0eeb9 --- /dev/null +++ b/App/Sources/Core/Runners/WorkflowRunner.swift @@ -0,0 +1,52 @@ +import Carbon +import Foundation +import MachPort + +final class WorkflowRunner { + private let commandRunner: CommandRunner + private let store: KeyCodesStore + private let notifications: MachPortUINotifications + + init(commandRunner: CommandRunner, store: KeyCodesStore, + notifications: MachPortUINotifications) { + self.commandRunner = commandRunner + self.store = store + self.notifications = notifications + } + + func run(_ workflow: Workflow, for shortcut: KeyShortcut, + executionOverride: Workflow.Execution? = nil, + machPortEvent: MachPortEvent, repeatingEvent: Bool) { + notifications.notifyRunningWorkflow(workflow) + let commands = workflow.commands.filter(\.isEnabled) + + /// Determines whether the command runner should check for cancellation. + /// If the workflow is triggered by a keyboard shortcut that is a passthrough and consists of only one shortcut, + /// and that shortcut is the escape key, then cancellation checking is disabled. + var checkCancellation: Bool = true + if let trigger = workflow.trigger, + case .keyboardShortcuts(let keyboardShortcutTrigger) = trigger, + keyboardShortcutTrigger.passthrough, + keyboardShortcutTrigger.shortcuts.count == 1 { + let shortcut = keyboardShortcutTrigger.shortcuts[0] + let displayValue = store.displayValue(for: kVK_Escape) + if shortcut.key == displayValue { + checkCancellation = false + } + } + + let resolveUserEnvironment = workflow.resolveUserEnvironment() + switch executionOverride ?? workflow.execution { + case .concurrent: + commandRunner.concurrentRun(commands, checkCancellation: checkCancellation, + resolveUserEnvironment: resolveUserEnvironment, + shortcut: shortcut, machPortEvent: machPortEvent, + repeatingEvent: repeatingEvent) + case .serial: + commandRunner.serialRun(commands, checkCancellation: checkCancellation, + resolveUserEnvironment: resolveUserEnvironment, + shortcut: shortcut, machPortEvent: machPortEvent, + repeatingEvent: repeatingEvent) + } + } +} diff --git a/App/Sources/Core/Stores/WindowStore.swift b/App/Sources/Core/Stores/WindowStore.swift index d0cddbdd..eaf3a41c 100644 --- a/App/Sources/Core/Stores/WindowStore.swift +++ b/App/Sources/Core/Stores/WindowStore.swift @@ -146,10 +146,15 @@ final class WindowStore { private func indexFrontmost() { do { + let forbiddenSubroles = [ + NSAccessibility.Subrole.systemDialog.rawValue, + NSAccessibility.Subrole.dialog.rawValue + ] state.frontMostApplicationWindows = try state.appAccessibilityElement.windows() .filter({ $0.id > 0 && - ($0.size?.height ?? 0) > 20 + ($0.size?.height ?? 0) > 20 && + !forbiddenSubroles.contains($0.subrole ?? "") }) } catch { } } diff --git a/App/Sources/UI/Menubar/HelpMenu.swift b/App/Sources/UI/Menubar/HelpMenu.swift index 4e55b1a1..021a0f01 100644 --- a/App/Sources/UI/Menubar/HelpMenu.swift +++ b/App/Sources/UI/Menubar/HelpMenu.swift @@ -1,7 +1,11 @@ import SwiftUI struct HelpMenu: View { + @Environment(\.openWindow) private var openWindow + var body: some View { + Button { openWindow(id: KeyboardCowboy.releaseNotesWindowIdentifier) } label: { Text("What's new?") } + Button(action: { NSWorkspace.shared.open(URL(string: "https://github.com/zenangst/KeyboardCowboy/wiki")!) }, label: { diff --git a/App/Sources/UI/Reducers/DetailCommandActionReducer.swift b/App/Sources/UI/Reducers/DetailCommandActionReducer.swift index e502688b..8ebc6181 100644 --- a/App/Sources/UI/Reducers/DetailCommandActionReducer.swift +++ b/App/Sources/UI/Reducers/DetailCommandActionReducer.swift @@ -1,5 +1,6 @@ import Foundation import Cocoa +import MachPort final class DetailCommandActionReducer { static func reduce(_ action: CommandView.Action, @@ -24,8 +25,11 @@ final class DetailCommandActionReducer { let runCommand = command Task { do { - try await commandRunner.run(runCommand, + guard let machPortEvent = MachPortEvent.empty() else { return } + try await commandRunner.run(runCommand, snapshot: UserSpace.shared.snapshot(resolveUserEnvironment: false), + shortcut: .empty(), + machPortEvent: machPortEvent, repeatingEvent: false) } catch let error as KeyboardCommandRunnerError { let alert = await NSAlert(error: error) @@ -150,11 +154,9 @@ final class DetailCommandActionReducer { switch action { case .updateSource(let model): let kind: ScriptCommand.Kind - switch model.scriptExtension { - case .appleScript: - kind = .appleScript - case .shellScript: - kind = .shellScript + kind = switch model.scriptExtension { + case .appleScript: .appleScript + case .shellScript: .shellScript } command = .script(.init(id: command.id, name: command.name, kind: kind, source: model.source, @@ -165,10 +167,13 @@ final class DetailCommandActionReducer { workflow.updateOrAddCommand(command) case .open(let source): Task { + guard let machPortEvent = MachPortEvent.empty() else { return } let path = (source as NSString).expandingTildeInPath try await commandRunner.run( .open(.init(path: path)), snapshot: UserSpace.shared.snapshot(resolveUserEnvironment: false), + shortcut: .empty(), + machPortEvent: machPortEvent, repeatingEvent: false ) } diff --git a/App/Sources/UI/Reducers/DetailViewActionReducer.swift b/App/Sources/UI/Reducers/DetailViewActionReducer.swift index 88cdcd02..aea74042 100644 --- a/App/Sources/UI/Reducers/DetailViewActionReducer.swift +++ b/App/Sources/UI/Reducers/DetailViewActionReducer.swift @@ -1,5 +1,6 @@ import Apps import Foundation +import MachPort import SwiftUI enum DetailViewActionReducerResult { @@ -143,27 +144,29 @@ final class DetailViewActionReducer { } } case .updateExecution(_, let execution): - switch execution { - case .concurrent: - workflow.execution = .concurrent - case .serial: - workflow.execution = .serial - } + switch execution { + case .concurrent: + workflow.execution = .concurrent + case .serial: + workflow.execution = .serial + } case .runWorkflow: + guard let machPortEvent = MachPortEvent.empty() else { return .none } + let commands = workflow.commands.filter(\.isEnabled) switch workflow.execution { case .concurrent: commandRunner.concurrentRun( - commands, - checkCancellation: true, - resolveUserEnvironment: true, + commands, checkCancellation: true, + resolveUserEnvironment: true, shortcut: .empty(), + machPortEvent: machPortEvent, repeatingEvent: false ) case .serial: commandRunner.serialRun( - commands, - checkCancellation: true, - resolveUserEnvironment: true, + commands, checkCancellation: true, + resolveUserEnvironment: true, shortcut: .empty(), + machPortEvent: machPortEvent, repeatingEvent: false ) } diff --git a/App/Sources/UI/Stores/AppStorageContainer.swift b/App/Sources/UI/Stores/AppStorageContainer.swift index f6cc9789..f8674495 100644 --- a/App/Sources/UI/Stores/AppStorageContainer.swift +++ b/App/Sources/UI/Stores/AppStorageContainer.swift @@ -1,7 +1,7 @@ import Foundation import SwiftUI -final class AppStorageContainer { +final class AppStorageContainer: @unchecked Sendable { #if DEBUG static let store = UserDefaults(suiteName: "com.zenangst.Keyboard-Cowboy.debug") #else @@ -16,4 +16,5 @@ final class AppStorageContainer { @AppStorage("selectedGroupIds", store: store) var groupIds = Set() @AppStorage("selectedWorkflowIds", store: store) var workflowIds = Set() @AppStorage("additionalApplicationPaths", store: store) var additionalApplicationPaths = [String]() + @AppStorage("ReleaseNotes") var releaseNotes: String = "" } diff --git a/App/Sources/UI/Views/Commands/BuiltInCommandView.swift b/App/Sources/UI/Views/Commands/BuiltInCommandView.swift index c3b9785a..03c4a45a 100644 --- a/App/Sources/UI/Views/Commands/BuiltInCommandView.swift +++ b/App/Sources/UI/Views/Commands/BuiltInCommandView.swift @@ -28,15 +28,41 @@ struct BuiltInCommandView: View { var body: some View { Group { CommandContainerView($metaData, placeholder: model.placheolder) { command in - switch command.icon.wrappedValue { - case .some(let icon): - IconView(icon: icon, size: iconSize) - case .none: - EmptyView() + + switch model.kind { + case .macro(let macroAction): + switch macroAction.kind { + case .record: MacroIconView(.record, size: iconSize.width) + case .remove: MacroIconView(.remove, size: iconSize.width) + } + case .userMode: + switch command.icon.wrappedValue { + case .some(let icon): IconView(icon: icon, size: iconSize) + case .none: EmptyView() + } } + } content: { _ in HStack { Menu(content: { + Button(action: { + let newKind: BuiltInCommand.Kind = .macro(.record) + onAction(.update(.init(id: model.id, kind: newKind, notification: true))) + model.name = newKind.displayValue + model.kind = newKind + }, label: { + Text("Record Macro").font(.subheadline) + }) + + Button(action: { + let newKind: BuiltInCommand.Kind = .macro(.remove) + onAction(.update(.init(id: model.id, kind: newKind, notification: true))) + model.name = newKind.displayValue + model.kind = newKind + }, label: { + Text("Remove Macro").font(.subheadline) + }) + Button( action: { let newKind: BuiltInCommand.Kind = .userMode(.init(id: model.kind.userModeId, name: model.name, isEnabled: true), .toggle) @@ -68,22 +94,28 @@ struct BuiltInCommandView: View { .font(.subheadline) }) .fixedSize() - Menu(content: { - ForEach(configurationPublisher.data.userModes) { userMode in - Button(action: { - let action: BuiltInCommand.Kind.Action - switch model.kind { - case .userMode(_, let resolvedAction): - action = resolvedAction + + switch model.kind { + case .macro(let macroAction): + EmptyView() + case .userMode(let userMode, let action): + Menu(content: { + ForEach(configurationPublisher.data.userModes) { userMode in + Button(action: { + let action: BuiltInCommand.Kind.Action + if case .userMode(_, let resolvedAction) = model.kind { + action = resolvedAction + onAction(.update(.init(id: model.id, kind: .userMode(userMode, action), notification: true))) + model.kind = .userMode(userMode, action) + } + }, label: { Text(userMode.name).font(.subheadline) }) } - onAction(.update(.init(id: model.id, kind: .userMode(userMode, action), notification: true))) - model.kind = .userMode(userMode, action) - }, label: { Text(userMode.name).font(.subheadline) }) - } - }, label: { - Text(configurationPublisher.data.userModes.first(where: { model.kind.id.contains($0.id) })?.name ?? "Pick a User Mode") - .font(.subheadline) - }) + }, label: { + Text(configurationPublisher.data.userModes.first(where: { model.kind.id.contains($0.id) })?.name ?? "Pick a User Mode") + .font(.subheadline) + }) + } + } .menuStyle(.regular) } subContent: { _ in diff --git a/App/Sources/UI/Views/ContentImageView.swift b/App/Sources/UI/Views/ContentImageView.swift index 4b5b9005..0de4f699 100644 --- a/App/Sources/UI/Views/ContentImageView.swift +++ b/App/Sources/UI/Views/ContentImageView.swift @@ -12,8 +12,20 @@ struct ContentImageView: View { ContentIconImageView(icon: icon, size: size) case .command(let kind): switch kind { - case .application, .open, .builtIn: + case .application, .open: EmptyView() + case .builtIn(let model): + switch model.kind { + case .macro(let action): + switch action.kind { + case .record: + MacroIconView(.record, size: size - 6) + case .remove: + MacroIconView(.remove, size: size - 6) + } + case .userMode: + EmptyView() + } case .keyboard(let model): if let firstKey = model.keys.first { KeyboardIconView(firstKey.key.uppercased(), size: size - 6) diff --git a/App/Sources/UI/Views/Extensions/KeyboardCowboy+Extensions.swift b/App/Sources/UI/Views/Extensions/KeyboardCowboy+Extensions.swift index 9de65a4b..9b6ea945 100644 --- a/App/Sources/UI/Views/Extensions/KeyboardCowboy+Extensions.swift +++ b/App/Sources/UI/Views/Extensions/KeyboardCowboy+Extensions.swift @@ -4,6 +4,7 @@ extension KeyboardCowboy { static let mainWindowIdentifier = "MainWindow" static let permissionsSettingsWindowIdentifier = "PermissionsSettingsWindow" static let permissionsWindowIdentifier = "PermissionsWindow" + static let releaseNotesWindowIdentifier = "ReleaseNotesWindow" static var bundleIdentifier: String { Bundle.main.bundleIdentifier! } diff --git a/App/Sources/UI/Views/Extensions/View+Extensions.swift b/App/Sources/UI/Views/Extensions/View+Extensions.swift index e8e17e96..f0b1af35 100644 --- a/App/Sources/UI/Views/Extensions/View+Extensions.swift +++ b/App/Sources/UI/Views/Extensions/View+Extensions.swift @@ -21,6 +21,41 @@ extension View { .clipShape(RoundedRectangle(cornerRadius: size * 0.2)) } + @ViewBuilder + func iconOverlay() -> some View { + AngularGradient(stops: [ + .init(color: Color.clear, location: 0.0), + .init(color: Color.white.opacity(0.2), location: 0.2), + .init(color: Color.clear, location: 1.0), + ], center: .bottomLeading) + + LinearGradient(stops: [ + .init(color: Color.white.opacity(0.2), location: 0), + .init(color: Color.clear, location: 0.3), + ], startPoint: .top, endPoint: .bottom) + + LinearGradient(stops: [ + .init(color: Color.clear, location: 0.8), + .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), + ], startPoint: .top, endPoint: .bottom) + + LinearGradient(stops: [ + .init(color: Color.clear, location: 0.8), + .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), + ], startPoint: .top, endPoint: .bottom) + } + + func iconBorder(_ size: CGFloat) -> some View { + LinearGradient(stops: [ + .init(color: Color(.white).opacity(0.15), location: 0.25), + .init(color: Color(.black).opacity(0.25), location: 1.0), + ], startPoint: .top, endPoint: .bottom) + .mask { + RoundedRectangle(cornerRadius: size * 0.2) + .stroke(lineWidth: size * 0.025) + } + } + @ViewBuilder func transform(_ transform: (Self) -> Transform) -> some View { transform(self) diff --git a/App/Sources/UI/Views/Icons/ActivateLastApplicationIconView.swift b/App/Sources/UI/Views/Icons/ActivateLastApplicationIconView.swift index d0eaf102..48c8d1b0 100644 --- a/App/Sources/UI/Views/Icons/ActivateLastApplicationIconView.swift +++ b/App/Sources/UI/Views/Icons/ActivateLastApplicationIconView.swift @@ -5,35 +5,26 @@ struct ActivateLastApplicationIconView: View { var body: some View { Rectangle() .fill(Color(nsColor: .systemPink)) - .overlay { - AngularGradient(stops: [ - .init(color: Color.clear, location: 0.0), - .init(color: Color.white.opacity(0.2), location: 0.2), - .init(color: Color.clear, location: 1.0), - ], center: .bottomLeading) - - LinearGradient(stops: [ - .init(color: Color.white.opacity(0.2), location: 0), - .init(color: Color.clear, location: 0.3), - ], startPoint: .top, endPoint: .bottom) - - LinearGradient(stops: [ - .init(color: Color.clear, location: 0.8), - .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), - ], startPoint: .top, endPoint: .bottom) - } - .overlay(alignment: .center) { - Image(systemName: "app") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: size * 0.7) - } + .overlay { iconOverlay().opacity(0.5) } + .overlay { iconBorder(size) } .overlay(alignment: .center) { - Image(systemName: "arrowshape.turn.up.backward.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: size * 0.35) - .offset(x: -size * 0.015, y: -size * 0.015) + LinearGradient(stops: [ + .init(color: Color(nsColor: .white), location: 0.35), + .init(color: Color(nsColor: .systemPink.blended(withFraction: 0.2, of: .white)!), location: 1.0), + ], startPoint: .topLeading, endPoint: .bottom) + .mask { + ZStack { + Image(systemName: "app") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size * 0.7) + Image(systemName: "arrowshape.turn.up.backward.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size * 0.35) + .offset(x: -size * 0.015, y: -size * 0.015) + } + } } .frame(width: size, height: size) .fixedSize() diff --git a/App/Sources/UI/Views/Icons/BugFixIconView.swift b/App/Sources/UI/Views/Icons/BugFixIconView.swift new file mode 100644 index 00000000..9c347143 --- /dev/null +++ b/App/Sources/UI/Views/Icons/BugFixIconView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct BugFixIconView: View { + let size: CGFloat + var body: some View { + Rectangle() + .fill( + LinearGradient(stops: [ + .init(color: Color(nsColor: .systemGreen), location: 0.25), + .init(color: Color(nsColor: .systemGreen.blended(withFraction: 0.25, of: NSColor.black)!), location: 1.0), + ], startPoint: .top, endPoint: .bottom) + ) + .overlay { iconOverlay().opacity(0.5) } + .overlay { iconBorder(size) } + .overlay { + LinearGradient(stops: [ + .init(color: Color(nsColor: .systemGreen.blended(withFraction: 0.8, of: .white)!), location: 0.4), + .init(color: Color(nsColor: .systemGreen), location: 1.0), + ], startPoint: .topLeading, endPoint: .bottom) + .mask { + Image(systemName: "ladybug") + .resizable() + .aspectRatio(contentMode: .fit) + } + .frame(width: size * 0.6) + .shadow(radius: 2) + } + .iconShape(size) + .frame(width: size, height: size) + } +} + +#Preview { + HStack(alignment: .top, spacing: 8) { + BugFixIconView(size: 192) + VStack(alignment: .leading, spacing: 8) { + BugFixIconView(size: 128) + HStack(alignment: .top, spacing: 8) { + BugFixIconView(size: 64) + BugFixIconView(size: 32) + BugFixIconView(size: 16) + } + } + } + .padding() +} + diff --git a/App/Sources/UI/Views/Icons/DockIconView.swift b/App/Sources/UI/Views/Icons/DockIconView.swift index ad9496ac..7832da89 100644 --- a/App/Sources/UI/Views/Icons/DockIconView.swift +++ b/App/Sources/UI/Views/Icons/DockIconView.swift @@ -6,23 +6,8 @@ struct DockIconView: View { var body: some View { RoundedRectangle(cornerRadius: 4) .fill(Color(.controlAccentColor)) - .overlay { - AngularGradient(stops: [ - .init(color: Color.clear, location: 0.0), - .init(color: Color.white.opacity(0.2), location: 0.2), - .init(color: Color.clear, location: 1.0), - ], center: .bottomLeading) - - LinearGradient(stops: [ - .init(color: Color.white.opacity(0.2), location: 0), - .init(color: Color.clear, location: 0.3), - ], startPoint: .top, endPoint: .bottom) - - LinearGradient(stops: [ - .init(color: Color.clear, location: 0.8), - .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), - ], startPoint: .top, endPoint: .bottom) - } + .overlay { iconOverlay() } + .overlay { iconBorder(size) } .overlay(alignment: .top) { Rectangle() .opacity(0.6) diff --git a/App/Sources/UI/Views/Icons/IconOverview.swift b/App/Sources/UI/Views/Icons/IconOverview.swift index f0be3017..7f5ef874 100644 --- a/App/Sources/UI/Views/Icons/IconOverview.swift +++ b/App/Sources/UI/Views/Icons/IconOverview.swift @@ -10,25 +10,29 @@ struct IconOverview: PreviewProvider { MenuIconView(size: size) MouseIconView(size: size) MissionControlIconView(size: size) + ScriptIconView(size: size) } HStack { - ScriptIconView(size: size) TypingIconView(size: size) UIElementIconView(size: size) WindowManagementIconView(size: size) MinimizeAllIconView(size: size) + BugFixIconView(size: size) + ImprovementIconView(size: size) } HStack { ActivateLastApplicationIconView(size: size) + MacroIconView(.record, size: size) + MacroIconView(.remove, size: size) MoveFocusToWindowIconView(direction: .previous, scope: .activeApplication, size: size) MoveFocusToWindowIconView(direction: .next, scope: .activeApplication, size: size) MoveFocusToWindowIconView(direction: .previous, scope: .visibleWindows, size: size) - MoveFocusToWindowIconView(direction: .next, scope: .visibleWindows, size: size) } HStack { + MoveFocusToWindowIconView(direction: .next, scope: .visibleWindows, size: size) MoveFocusToWindowIconView(direction: .previous, scope: .allWindows, size: size) MoveFocusToWindowIconView(direction: .next, scope: .allWindows, size: size) } diff --git a/App/Sources/UI/Views/Icons/ImprovementIconView.swift b/App/Sources/UI/Views/Icons/ImprovementIconView.swift new file mode 100644 index 00000000..fc451a22 --- /dev/null +++ b/App/Sources/UI/Views/Icons/ImprovementIconView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct ImprovementIconView: View { + let size: CGFloat + + var body: some View { + Rectangle() + .fill( + LinearGradient(stops: [ + .init(color: Color(nsColor: .textBackgroundColor), location: 0.25), + .init(color: Color(nsColor: .textBackgroundColor.blended(withFraction: 0.1, of: .black)!), location: 1), + ], startPoint: .top, endPoint: .bottom) + ) + .overlay { iconOverlay() } + .overlay { iconBorder(size) } + .overlay { + LinearGradient(stops: [ + .init(color: Color(.systemYellow), location: 0), + .init(color: Color.orange, location: 1) + ], startPoint: .top, endPoint: .bottom) + .mask { + VStack(alignment: .leading, spacing: 0) { + Text("LEVEL") + .font(Font.system(size: size * 0.25, weight: .heavy, design: .rounded)) + HStack(spacing: 0) { + Text("UP") + .font(Font.system(size: size * 0.3, weight: .heavy, design: .rounded)) + Image(systemName: "arrowshape.turn.up.left.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .rotationEffect(.degrees(90)) + .frame(width: size * 0.35) + } + } + } + .padding(size * 0.05) + .shadow(color: Color(.systemYellow), radius: 3, y: 1) + } + .iconShape(size) + .frame(width: size, height: size) + } +} + +#Preview { + HStack(alignment: .top, spacing: 8) { + ImprovementIconView(size: 192) + VStack(alignment: .leading, spacing: 8) { + ImprovementIconView(size: 128) + HStack(alignment: .top, spacing: 8) { + ImprovementIconView(size: 64) + ImprovementIconView(size: 32) + ImprovementIconView(size: 16) + } + } + } + .padding() +} + diff --git a/App/Sources/UI/Views/Icons/KeyboardIconView.swift b/App/Sources/UI/Views/Icons/KeyboardIconView.swift index 0cb882c0..73118127 100644 --- a/App/Sources/UI/Views/Icons/KeyboardIconView.swift +++ b/App/Sources/UI/Views/Icons/KeyboardIconView.swift @@ -15,6 +15,7 @@ struct KeyboardIconView: View { var body: some View { Rectangle() .fill(Color(.textBackgroundColor)) + .overlay { iconOverlay().opacity(0.2) } .overlay { AngularGradient(stops: [ .init(color: Color.clear, location: 0.0), @@ -32,6 +33,7 @@ struct KeyboardIconView: View { .init(color: Color(.windowBackgroundColor), location: 1.0), ], startPoint: .top, endPoint: .bottom) } + .overlay { iconBorder(size).opacity(0.5) } .overlay { Text(letter) .font(Font.system(size: size * 0.3, weight: .regular, design: .rounded)) diff --git a/App/Sources/UI/Views/Icons/MacroIconView.swift b/App/Sources/UI/Views/Icons/MacroIconView.swift new file mode 100644 index 00000000..1229c833 --- /dev/null +++ b/App/Sources/UI/Views/Icons/MacroIconView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +struct MacroIconView: View { + enum Kind { + case record + case remove + } + let kind: Kind + let size: CGFloat + + init(_ kind: Kind, size: CGFloat) { + self.kind = kind + self.size = size + } + + var body: some View { + let color = Color(kind == .record + ? .systemCyan + : .systemYellow) + Rectangle() + .fill(color) + .overlay { iconOverlay().opacity(0.25) } + .overlay { iconBorder(size) } + .overlay(alignment: .center) { + backgroundShape(color: color) + .scaleEffect(0.8) + .rotation3DEffect(.degrees(30), axis: (x: 1.0, y: 0.0, z: 0.0)) + .offset(y: -size * 0.225) + + backgroundShape(color: color) + .scaleEffect(0.9) + .rotation3DEffect(.degrees(15), axis: (x: 1.0, y: 0.0, z: 0.0)) + .offset(y: -size * 0.125) + + Text("MACRO") + .frame(minWidth: size * 0.9, minHeight: size * 0.4) + .font(Font.system(size: size * 0.225, weight: .heavy, design: .rounded)) + .foregroundColor(color) + .allowsTightening(true) + .padding(size * 0.02) + .overlay { + RoundedRectangle(cornerRadius: size * 0.05) + .fill(.black) + .frame(width: size * 0.9, height: size * 0.025) + .opacity(kind == .record ? 0 : 1) + } + .background { + RoundedRectangle(cornerRadius: size * 0.05) + .fill(Color(.textBackgroundColor)) + .overlay(overlay()) + .shadow(radius: 2, y: 2) + } + } + .overlay(alignment: .bottomTrailing) { + RoundedRectangle(cornerRadius: size * 0.05) + .fill(Color(kind == .record ? .systemGreen : .systemRed)) + .overlay { + Image(systemName: kind == .record ? "plus" : "minus") + .resizable() + .aspectRatio(contentMode: .fit) + .fontWeight(.heavy) + .frame(width: size * 0.15, height: size * 0.15) + } + .overlay(content: { + overlay() + .mask(RoundedRectangle(cornerRadius: size * 0.05)) + .shadow(radius: 2) + }) + .background { + RoundedRectangle(cornerRadius: size * 0.05) + .stroke(Color(.white).opacity(0.75), lineWidth: size * 0.0075) + .offset(y: size * 0.015) + } + .frame(width: size * 0.25, height: size * 0.25) + .offset(x: -size * 0.075, y: -size * 0.075) + } + .frame(width: size, height: size) + .fixedSize() + .iconShape(size) + } + + @ViewBuilder + func overlay() -> some View { + AngularGradient(stops: [ + .init(color: Color.clear, location: 0.0), + .init(color: Color.white.opacity(0.2), location: 0.2), + .init(color: Color.clear, location: 1.0), + ], center: .bottomLeading) + + LinearGradient(stops: [ + .init(color: Color.white.opacity(0.2), location: 0), + .init(color: Color.clear, location: 0.3), + ], startPoint: .top, endPoint: .bottom) + + LinearGradient(stops: [ + .init(color: Color.clear, location: 0.8), + .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), + ], startPoint: .top, endPoint: .bottom) + } + + func backgroundShape(color: Color) -> some View { + Rectangle() + .fill(color) + .overlay { + RoundedRectangle(cornerRadius: size * 0.05) + .stroke(Color(.textBackgroundColor), lineWidth: size * 0.0175) + } + .frame(width: size * 0.9, height: size * 0.4) + .shadow(radius: 2, y: 2) + } +} + +#Preview { + VStack { + HStack(alignment: .top, spacing: 8) { + MacroIconView(.record, size: 192) + VStack(alignment: .leading, spacing: 8) { + MacroIconView(.record, size: 128) + HStack(alignment: .top, spacing: 8) { + MacroIconView(.record, size: 64) + MacroIconView(.record, size: 32) + MacroIconView(.record, size: 16) + } + } + } + + HStack(alignment: .top, spacing: 8) { + MacroIconView(.remove, size: 192) + VStack(alignment: .leading, spacing: 8) { + MacroIconView(.remove, size: 128) + HStack(alignment: .top, spacing: 8) { + MacroIconView(.remove, size: 64) + MacroIconView(.remove, size: 32) + MacroIconView(.remove, size: 16) + } + } + } + } + .padding() +} diff --git a/App/Sources/UI/Views/Icons/MenuIconView.swift b/App/Sources/UI/Views/Icons/MenuIconView.swift index 004fe00f..39fb5a2c 100644 --- a/App/Sources/UI/Views/Icons/MenuIconView.swift +++ b/App/Sources/UI/Views/Icons/MenuIconView.swift @@ -17,6 +17,8 @@ struct MenuIconView: View { endPoint: .bottom ) ) + .overlay { iconOverlay().opacity(0.65) } + .overlay { iconBorder(size) } .overlay(alignment: .top) { Rectangle() .fill(Color(.white).opacity(0.4)) diff --git a/App/Sources/UI/Views/Icons/MinimizeAllIconView.swift b/App/Sources/UI/Views/Icons/MinimizeAllIconView.swift index f0f1b0c9..e348c399 100644 --- a/App/Sources/UI/Views/Icons/MinimizeAllIconView.swift +++ b/App/Sources/UI/Views/Icons/MinimizeAllIconView.swift @@ -6,23 +6,8 @@ struct MinimizeAllIconView: View { var body: some View { Rectangle() .fill(Color(nsColor: .systemBrown)) - .overlay { - AngularGradient(stops: [ - .init(color: Color.clear, location: 0.0), - .init(color: Color.white.opacity(0.2), location: 0.2), - .init(color: Color.clear, location: 1.0), - ], center: .bottomLeading) - - LinearGradient(stops: [ - .init(color: Color.white.opacity(0.2), location: 0), - .init(color: Color.clear, location: 0.3), - ], startPoint: .top, endPoint: .bottom) - - LinearGradient(stops: [ - .init(color: Color.clear, location: 0.8), - .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), - ], startPoint: .top, endPoint: .bottom) - } + .overlay { iconOverlay() } + .overlay { iconBorder(size) } .overlay { window(.init(width: (size * 0.85) * 0.8, height: (size * 0.75) * 0.8)) diff --git a/App/Sources/UI/Views/Icons/MissionControlIconView.swift b/App/Sources/UI/Views/Icons/MissionControlIconView.swift index fc345cdf..6a9b2d55 100644 --- a/App/Sources/UI/Views/Icons/MissionControlIconView.swift +++ b/App/Sources/UI/Views/Icons/MissionControlIconView.swift @@ -6,23 +6,8 @@ struct MissionControlIconView: View { var body: some View { RoundedRectangle(cornerRadius: 4) .fill(Color(nsColor: .systemIndigo)) - .overlay { - AngularGradient(stops: [ - .init(color: Color.clear, location: 0.0), - .init(color: Color.white.opacity(0.2), location: 0.2), - .init(color: Color.clear, location: 1.0), - ], center: .bottomLeading) - - LinearGradient(stops: [ - .init(color: Color.white.opacity(0.2), location: 0), - .init(color: Color.clear, location: 0.3), - ], startPoint: .top, endPoint: .bottom) - - LinearGradient(stops: [ - .init(color: Color.clear, location: 0.8), - .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), - ], startPoint: .top, endPoint: .bottom) - } + .overlay { iconOverlay().opacity(0.65) } + .overlay { iconBorder(size) } .overlay(alignment: .center, content: { HStack(spacing: size * 0.03) { VStack(alignment: .trailing, spacing: size * 0.08) { diff --git a/App/Sources/UI/Views/Icons/MouseIconView.swift b/App/Sources/UI/Views/Icons/MouseIconView.swift index 8b9d108f..30573a5f 100644 --- a/App/Sources/UI/Views/Icons/MouseIconView.swift +++ b/App/Sources/UI/Views/Icons/MouseIconView.swift @@ -5,23 +5,9 @@ struct MouseIconView: View { var body: some View { Rectangle() .fill(Color(.systemGreen)) + .overlay { iconOverlay().opacity(0.65) } + .overlay { iconBorder(size) } .overlay { - AngularGradient(stops: [ - .init(color: Color.clear, location: 0.0), - .init(color: Color.white.opacity(0.2), location: 0.2), - .init(color: Color.clear, location: 1.0), - ], center: .bottomLeading) - - LinearGradient(stops: [ - .init(color: Color.white.opacity(0.2), location: 0), - .init(color: Color.clear, location: 0.3), - ], startPoint: .top, endPoint: .bottom) - - LinearGradient(stops: [ - .init(color: Color.clear, location: 0.8), - .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), - ], startPoint: .top, endPoint: .bottom) - Capsule() .fill(Color(.white)) .frame(width: size * 0.45, height: size * 0.8) diff --git a/App/Sources/UI/Views/Icons/MoveFocusToWindowIconView.swift b/App/Sources/UI/Views/Icons/MoveFocusToWindowIconView.swift index e40b7b36..383e258d 100644 --- a/App/Sources/UI/Views/Icons/MoveFocusToWindowIconView.swift +++ b/App/Sources/UI/Views/Icons/MoveFocusToWindowIconView.swift @@ -18,6 +18,7 @@ struct MoveFocusToWindowIconView: View { RoundedRectangle(cornerRadius: 4) .fill(baseColor(for: scope)) .overlay { background() } + .overlay { iconBorder(size) } .overlay(content: { HStack(spacing: size * 0.0_140) { if scope == .allWindows { diff --git a/App/Sources/UI/Views/Icons/Releases.swift b/App/Sources/UI/Views/Icons/Releases.swift index aced9198..4f3b7f2a 100644 --- a/App/Sources/UI/Views/Icons/Releases.swift +++ b/App/Sources/UI/Views/Icons/Releases.swift @@ -1,5 +1,169 @@ +import Bonzai import SwiftUI +struct Release3_22_0: View { + enum ButtonAction { + case wiki + case done + } + + let size: CGFloat = 128 + let action: (ButtonAction) -> Void + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 16) { + ZStack { + MacroIconView(.record, size: size) + .scaleEffect(0.8) + .offset(y: -24) + .opacity(0.5) + MacroIconView(.record, size: size) + .scaleEffect(0.9) + .offset(y: -12) + .shadow(radius: 2) + .opacity(0.5) + MacroIconView(.record, size: size) + .shadow(radius: 2) + } + VStack(alignment: .leading) { + Text("Keyboard Cowboy") + .font(Font.system(size: 16, design: .rounded)) + Text("3.22.0") + .foregroundStyle(.white) + .font(Font.system(size: 43, design: .rounded)) + .allowsTightening(true) + .fontWeight(.heavy) + .shadow(color: Color(.systemCyan), radius: 10) + } + .shadow(radius: 2) + .frame(width: 268, height: size) + .fixedSize() + .background { + Rectangle() + .fill(.black) + .overlay { + AngularGradient(stops: [ + .init(color: Color.clear, location: 0.0), + .init(color: Color.white.opacity(0.2), location: 0.2), + .init(color: Color.clear, location: 1.0), + ], center: .bottomLeading) + + LinearGradient(stops: [ + .init(color: Color.white.opacity(0.2), location: 0), + .init(color: Color.clear, location: 0.3), + ], startPoint: .top, endPoint: .bottom) + + LinearGradient(stops: [ + .init(color: Color.clear, location: 0.8), + .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), + ], startPoint: .top, endPoint: .bottom) + + } + } + .iconShape(size) + } + .padding(.top, 32) + + ZenDivider() + .frame(maxWidth: 400) + .padding(.top, 8) + + VStack(alignment: .leading, spacing: 16) { + ScrollView(.vertical) { + VStack(spacing: 6) { + HStack(spacing: 12) { + MacroIconView(.record, size: 32) + Text("Macros: Make Every Keystroke Count!") + .font(Font.system(.title2, design: .rounded, weight: .bold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + Text("Rev up your routine with Macros — where doing less isn't just more; it's epic. Say hello to keyboard wizardry that conjures up workflows in the blink of an eye.") + .fixedSize(horizontal: false, vertical: true) + Text("With Macros, you're not just efficient, you're effortlessly epic!") + + Divider() + .padding(.vertical, 8) + + Text("Other changes") + .font(Font.system(.headline, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + BugFixIconView(size: 24) + Text("Banishes the ghosted icons — they're all back with a vengeance!") + } + + HStack(alignment: .top, spacing: 12) { + ImprovementIconView(size: 24) + VStack(spacing: 12) { + Text("Keyboard-, Menubar- & Window Management Commands now work better with repeating events") + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Window-hopping within your go-to app just got a snappy upgrade—slick, reliable, and ready to roll!") + .frame(maxWidth: .infinity, alignment: .leading) + + Text("Icons revamped – a subtle glow-up for your visual pleasure!") + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .font(Font.system(.caption2, design: .rounded)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .frame(minHeight: 220) + + Divider() + + HStack(spacing: 4) { + Text("Special thanks to") + AsyncImage.init(url: URL(string: "https://avatars.githubusercontent.com/u/5180591?v=4")) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .mask { Circle() } + } placeholder: { + Circle() + .fill(Color(.controlAccentColor)) + .frame(width: 24, height: 24) + .overlay { + ProgressView() + } + } + Link("@bforpc", destination: URL(string: "https://github.com/bforpc")!) + Text("for supporting the project ❤️") + } + } + .frame(width: 380) + .roundedContainer(margin: 0) + .padding(.top, 8) + .padding(.horizontal, 16) + + HStack(spacing: 8) { + Button(action: { action(.wiki) }, label: { Text("About Macros") }) + .buttonStyle(.zen(.init(color: .systemCyan, hoverEffect: .constant(false)))) + + Button(action: { action(.done) }, label: { Text("Got it!") }) + .buttonStyle(.zen(.init(color: .systemGreen, hoverEffect: .constant(false)))) + } + .padding(.top, 8) + .padding(.bottom, 32) + .frame(width: 410) + } + .background(Color(.windowBackgroundColor)) + } +} + +struct Release3_22_0_Previews: PreviewProvider { + static var previews: some View { + Release3_22_0 { _ in } + .previewDisplayName("Release 3.22.0") + } +} + struct Release3_21_0: PreviewProvider { static let size: CGFloat = 96 static var previews: some View { @@ -66,6 +230,7 @@ struct Release3_21_0: PreviewProvider { } .padding(64) .background(Color(.windowBackgroundColor)) + .previewDisplayName("Release 3.21.0") } } diff --git a/App/Sources/UI/Views/Icons/ScriptIconView.swift b/App/Sources/UI/Views/Icons/ScriptIconView.swift index da0a8ac3..e3aeb547 100644 --- a/App/Sources/UI/Views/Icons/ScriptIconView.swift +++ b/App/Sources/UI/Views/Icons/ScriptIconView.swift @@ -5,27 +5,12 @@ struct ScriptIconView: View { var body: some View { Rectangle() .fill(Color(.black)) - .overlay { - AngularGradient(stops: [ - .init(color: Color.clear, location: 0.0), - .init(color: Color(.controlAccentColor).opacity(0.5), location: 0.2), - .init(color: Color.clear, location: 1.0), - ], center: .bottomLeading) - - LinearGradient(stops: [ - .init(color: Color.white.opacity(0.2), location: 0), - .init(color: Color.clear, location: 0.3), - ], startPoint: .top, endPoint: .bottom) - - LinearGradient(stops: [ - .init(color: Color.clear, location: 0.8), - .init(color: Color(.windowBackgroundColor).opacity(0.3), location: 1.0), - ], startPoint: .top, endPoint: .bottom) - } + .overlay { iconOverlay() } + .overlay { iconBorder(size) } .overlay(alignment: .topLeading) { HStack(spacing: 0) { Text(">") - .font(Font.system(size: size * 0.375, design: .monospaced)) + .font(Font.system(size: size * 0.375, weight: .regular, design: .rounded)) .padding(.top, size * 0.05) .padding(.leading, size * 0.1) .foregroundColor( @@ -33,33 +18,14 @@ struct ScriptIconView: View { ) .shadow(color: .white, radius: 15, y: 5) Text("_") - .font(Font.system(size: size * 0.375, design: .monospaced)) + .font(Font.system(size: size * 0.375, weight: .regular, design: .rounded)) .padding(.top, size * 0.05) .foregroundColor( Color(nsColor: .controlAccentColor.withSystemEffect(.deepPressed)) ) - .shadow(color: .white, radius: 10, y: 5) + .shadow(color: Color(.controlAccentColor), radius: 10, y: 2) } } - .overlay { - RoundedRectangle(cornerRadius: size * 0.175) - .stroke( LinearGradient(stops: [ - .init(color: Color(.windowBackgroundColor), location: 0.0), - .init(color: Color(.systemGray), location: 0.2), - .init(color: Color(.windowBackgroundColor), location: 1.0), - ], startPoint: .topLeading, endPoint: .bottomTrailing) - , lineWidth: size * 0.0_5) - -// RoundedRectangle(cornerRadius: size * 0.105) -// .stroke( LinearGradient(stops: [ -// .init(color: Color(.windowBackgroundColor), location: 0.0), -// .init(color: Color(.systemGray), location: 0.2), -// .init(color: Color(.windowBackgroundColor), location: 1.0), -// ], startPoint: .bottomTrailing, endPoint: .topLeading) -// , lineWidth: size * 0.0_175) -// .padding(size * 0.0_24) - - } .frame(width: size, height: size) .fixedSize() .iconShape(size) diff --git a/App/Sources/UI/Views/Icons/TypingIconView.swift b/App/Sources/UI/Views/Icons/TypingIconView.swift index 761b805e..dd080fba 100644 --- a/App/Sources/UI/Views/Icons/TypingIconView.swift +++ b/App/Sources/UI/Views/Icons/TypingIconView.swift @@ -11,6 +11,8 @@ Here’s to the crazy ones. The misfits. The rebels. The troublemakers. The roun var body: some View { Rectangle() .fill(.white) + .overlay { iconOverlay().opacity(0.5) } + .overlay { iconBorder(size) } .overlay(alignment: .leading) { Rectangle() .fill(Color(.systemRed).opacity(0.25)) diff --git a/App/Sources/UI/Views/Icons/UIElementIconView.swift b/App/Sources/UI/Views/Icons/UIElementIconView.swift index b7abd6ad..8674ffe6 100644 --- a/App/Sources/UI/Views/Icons/UIElementIconView.swift +++ b/App/Sources/UI/Views/Icons/UIElementIconView.swift @@ -45,6 +45,8 @@ struct UIElementIconView: View { endPoint: .leading ) } + .overlay { iconOverlay() } + .overlay { iconBorder(size) } .overlay(alignment: .center) { Image(systemName: "viewfinder") .resizable() diff --git a/App/Sources/UI/Views/Icons/WindowManagementIconView.swift b/App/Sources/UI/Views/Icons/WindowManagementIconView.swift index 81eddc4a..04a13f93 100644 --- a/App/Sources/UI/Views/Icons/WindowManagementIconView.swift +++ b/App/Sources/UI/Views/Icons/WindowManagementIconView.swift @@ -23,6 +23,7 @@ struct WindowManagementIconView: View { window() .frame(width: size * 0.182) } + .overlay { iconBorder(size) } .background() .compositingGroup() .iconShape(size) diff --git a/App/Sources/UI/Views/Mappers/ContentModelMapper.swift b/App/Sources/UI/Views/Mappers/ContentModelMapper.swift index f348ac8e..ad0d15b1 100644 --- a/App/Sources/UI/Views/Mappers/ContentModelMapper.swift +++ b/App/Sources/UI/Views/Mappers/ContentModelMapper.swift @@ -127,13 +127,23 @@ private extension Array where Element == Command { case .menuBar(let command): images.append(.init(id: command.id, offset: convertedOffset, kind: .command(.menuBar(.init(id: command.id, tokens: command.tokens))))) case .builtIn(let command): - let path = Bundle.main.bundleURL.path - images.append( - ContentViewModel.ImageModel( - id: command.id, - offset: convertedOffset, - kind: .icon(.init(bundleIdentifier: path, path: path))) - ) + switch command.kind { + case .macro(let action): + switch action.kind { + case .record: + images.append(.init(id: command.id, offset: convertedOffset, kind: .command(.builtIn(.init(id: command.id, name: command.name, kind: .macro(.record)))))) + case .remove: + images.append(.init(id: command.id, offset: convertedOffset, kind: .command(.builtIn(.init(id: command.id, name: command.name, kind: .macro(.remove)))))) + } + case .userMode: + let path = Bundle.main.bundleURL.path + images.append( + ContentViewModel.ImageModel( + id: command.id, + offset: convertedOffset, + kind: .icon(.init(bundleIdentifier: path, path: path))) + ) + } case .mouse(let command): images.append(.init(id: command.id, offset: convertedOffset, kind: .command(.mouse(.init(id: command.id, kind: command.kind))))) case .keyboard(let keyCommand): diff --git a/App/Sources/UI/Views/NewCommand/NewCommandBuiltInView.swift b/App/Sources/UI/Views/NewCommand/NewCommandBuiltInView.swift index f019c223..e1a75b78 100644 --- a/App/Sources/UI/Views/NewCommand/NewCommandBuiltInView.swift +++ b/App/Sources/UI/Views/NewCommand/NewCommandBuiltInView.swift @@ -19,24 +19,50 @@ struct NewCommandBuiltInView: View { VStack(alignment: .leading) { ZenLabel("Built-In Commands") .frame(maxWidth: .infinity, alignment: .leading) - HStack { - Menu(content: { - Button(action: { kindSelection = .userMode(userModeSelection, .toggle) }, label: { Text("Toggle") }) - Button(action: { kindSelection = .userMode(userModeSelection, .enable) }, label: { Text("Enable") }) - Button(action: { kindSelection = .userMode(userModeSelection, .disable) }, label: { Text("Disable") }) - }, label: { - Text(kindSelection.displayValue) - }) + switch kindSelection { + case .macro(let macroAction): + switch macroAction.kind { + case .record: + MacroIconView(.record, size: 24) + case .remove: + MacroIconView(.remove, size: 24) + } + case .userMode: + let path = Bundle.main.bundleURL.path + IconView(icon: .init(bundleIdentifier: path, path: path), size: CGSize(width: 24, height: 24)) + } + + VStack { - Menu(content: { - ForEach(publisher.data.userModes) { userMode in - Button(action: { userModeSelection = userMode }, - label: { Text(userMode.name) }) + Menu { + Button(action: { kindSelection = .userMode(.init(id: UUID().uuidString, name: "", isEnabled: true), .toggle) }, + label: { Text("User Mode") }) + Button(action: { kindSelection = .macro(.record) }, + label: { Text("Record Macros") }) + Button(action: { kindSelection = .macro(.remove) }, + label: { Text("Remove Macros") }) + } label: { + switch kindSelection { + case .macro(let action): + switch action.kind { + case .record: + Text("Record Macro") + case .remove: + Text("Remove Macro") + } + case .userMode: + Text("User Mode") + } } - }, label: { - Text(publisher.data.userModes.first(where: { $0.id == userModeSelection.id })?.name ?? "Pick a User Mode") - }) + } + } + + switch kindSelection { + case .macro: + EmptyView() + case .userMode: + userMode() } } .onChange(of: kindSelection, perform: { newValue in @@ -56,15 +82,43 @@ struct NewCommandBuiltInView: View { .menuStyle(.regular) } + func userMode() -> some View { + HStack { + Menu(content: { + Button(action: { kindSelection = .userMode(userModeSelection, .toggle) }, label: { Text("Toggle") }) + Button(action: { kindSelection = .userMode(userModeSelection, .enable) }, label: { Text("Enable") }) + Button(action: { kindSelection = .userMode(userModeSelection, .disable) }, label: { Text("Disable") }) + }, label: { + Text(kindSelection.displayValue) + }) + + Menu(content: { + ForEach(publisher.data.userModes) { userMode in + Button(action: { userModeSelection = userMode }, + label: { Text(userMode.name) }) + } + }, label: { + Text(publisher.data.userModes.first(where: { $0.id == userModeSelection.id })?.name ?? "Pick a User Mode") + }) + } + } + @discardableResult private func updateAndValidatePayload() -> NewCommandValidation { - let newKind: BuiltInCommand.Kind = switch kindSelection { - case .userMode(_, let action): .userMode(userModeSelection, action) + let validation: Bool + let newKind: BuiltInCommand.Kind + switch kindSelection { + case .macro(let action): + validation = true + newKind = .macro(action) + case .userMode(_, let action): + validation = !userModeSelection.name.isEmpty + newKind = .userMode(userModeSelection, action) } payload = .builtIn(builtIn: .init(kind: newKind, notification: false)) - return !userModeSelection.name.isEmpty ? .valid : .invalid(reason: "Please select a User Mode.") + return validation ? .valid : .invalid(reason: "Please select a User Mode.") } } diff --git a/App/Sources/UI/Views/Windows/ReleaseNotesScene.swift b/App/Sources/UI/Views/Windows/ReleaseNotesScene.swift new file mode 100644 index 00000000..6242af16 --- /dev/null +++ b/App/Sources/UI/Views/Windows/ReleaseNotesScene.swift @@ -0,0 +1,28 @@ +import Cocoa +import SwiftUI + +struct ReleaseNotesScene: Scene { + let appStorage = AppStorageContainer.shared + @Environment(\.dismiss) var dismiss + + var body: some Scene { + WindowGroup(id: KeyboardCowboy.releaseNotesWindowIdentifier) { + Release3_22_0 { action in + switch action { + case .done: + break + case .wiki: + NSWorkspace.shared.open(URL(string: "https://github.com/zenangst/KeyboardCowboy/wiki/Commands#macros")!) + } + NSApplication.shared.keyWindow?.close() + } + .onDisappear { + AppStorageContainer.shared.releaseNotes = KeyboardCowboy.marektingVersion + } + } + .windowResizability(.contentSize) + .windowToolbarStyle(.unified(showsTitle: true)) + .windowStyle(.hiddenTitleBar) + .defaultPosition(.center) + } +} diff --git a/App/Sources/UI/Windows/MainWindow.swift b/App/Sources/UI/Windows/MainWindow.swift index 8a7c859a..264a33cf 100644 --- a/App/Sources/UI/Windows/MainWindow.swift +++ b/App/Sources/UI/Windows/MainWindow.swift @@ -5,6 +5,7 @@ struct MainWindow: Scene { private let core: Core @FocusState var focus: AppFocus? + @Environment(\.openWindow) private var openWindow private let onScene: (AppScene) -> Void init(_ core: Core, onScene: @escaping (AppScene) -> Void) { @@ -43,6 +44,7 @@ struct MainWindow: Scene { .commands { CommandGroup(after: .appSettings) { AppMenu() + Button { openWindow(id: KeyboardCowboy.releaseNotesWindowIdentifier) } label: { Text("What's new?") } } CommandGroup(replacing: .newItem) { FileMenu( diff --git a/Project.swift b/Project.swift index 6b97719c..abd5c38e 100644 --- a/Project.swift +++ b/Project.swift @@ -59,10 +59,10 @@ let project = Project( "ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon", "CODE_SIGN_IDENTITY": "Apple Development", "CODE_SIGN_STYLE": "Automatic", - "CURRENT_PROJECT_VERSION": "454", + "CURRENT_PROJECT_VERSION": "512", "DEVELOPMENT_TEAM": env["TEAM_ID"], "ENABLE_HARDENED_RUNTIME": true, - "MARKETING_VERSION": "3.21.2", + "MARKETING_VERSION": "3.22.0", "PRODUCT_NAME": "Keyboard Cowboy" ], configurations: [ @@ -165,7 +165,7 @@ public enum PackageResolver { packages = [ .package(url: "https://github.com/krzysztofzablocki/Inject.git", from: "1.1.0"), .package(url: "https://github.com/sparkle-project/Sparkle.git", from: "2.4.1"), - .package(url: "https://github.com/zenangst/AXEssibility.git", from: "0.1.1"), + .package(url: "https://github.com/zenangst/AXEssibility.git", from: "0.1.2"), .package(url: "https://github.com/zenangst/Bonzai.git", .revision("cd94e615fa183c82395a783881c211c26255ad40")), .package(url: "https://github.com/zenangst/Apps.git", from: "1.4.2"), .package(url: "https://github.com/zenangst/Dock.git", from: "1.0.1"), diff --git a/UnitTests/Sources/Controllers/ApplicationTriggerControllerTests.swift b/UnitTests/Sources/Controllers/ApplicationTriggerControllerTests.swift index 5ed6c3ff..24eb66ac 100644 --- a/UnitTests/Sources/Controllers/ApplicationTriggerControllerTests.swift +++ b/UnitTests/Sources/Controllers/ApplicationTriggerControllerTests.swift @@ -2,6 +2,7 @@ import XCTest import Combine import Cocoa +import MachPort final class ApplicationTriggerControllerTests: XCTestCase { func testApplicationTriggerController_frontMost() { @@ -114,11 +115,11 @@ private final class CommandRunner: CommandRunning { self.serialRunHandler = serial } - func concurrentRun(_ commands: [Command], checkCancellation: Bool, resolveUserEnvironment: Bool) { + func concurrentRun(_ commands: [Command], checkCancellation: Bool, resolveUserEnvironment: Bool, shortcut: KeyShortcut, machPortEvent: MachPortEvent, repeatingEvent: Bool) { concurrentRunHandler(commands) } - func serialRun(_ commands: [Command], checkCancellation: Bool, resolveUserEnvironment: Bool) { + func serialRun(_ commands: [Command], checkCancellation: Bool, resolveUserEnvironment: Bool, shortcut: KeyShortcut, machPortEvent: MachPortEvent, repeatingEvent: Bool) { serialRunHandler(commands) } }