diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift index bc4877773..1ff0813c6 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/ExtensionsPane.swift @@ -1067,6 +1067,7 @@ private struct ExtensionDetailRows: View { case .focusDim: FocusDimRows() case .presenter: PresenterRows() case .colorPicker: ColorPickerRows() + case .radialLauncher: RadialLauncherRows() case .emoji: EmojiRows() } } else { diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/RadialLauncherRows.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/RadialLauncherRows.swift new file mode 100644 index 000000000..130607cd2 --- /dev/null +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/RadialLauncherRows.swift @@ -0,0 +1,277 @@ +import AppKit +import Carbon.HIToolbox +import EdithKit +import SwiftUI +import UniformTypeIdentifiers + +struct RadialLauncherRows: View { + @AppStorage(RadialLauncherPreferenceKeys.enabled, store: SharedDefaults.store) private + var enabled = false + @AppStorage(RadialLauncherPreferenceKeys.atPointer, store: SharedDefaults.store) private + var atPointer = true + @State private var profile = RadialLauncherProfile.starter + @State private var loaded = false + + var body: some View { + Section("Profile") { + TextField("Profile name", text: $profile.name) + Toggle("Center on pointer", isOn: $atPointer) + HStack { + HotKeyRecorderControl( + keyPrefix: "radialLauncherHotKey", defaultLabel: "⌥⌘Space") + Button("Try launcher") { IPC.post(IPC.Name.requestRadialLauncher) } + .disabled(!profile.items.contains(where: \.isConfigured)) + } + Text( + "Press the shortcut, point at a slice, then release. A quick press leaves the wheel open for clicking or number keys." + ) + .settingsCaption() + } + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + + Section("Wheel actions") { + ForEach(Array(profile.items.enumerated()), id: \.element.id) { index, item in + RadialLauncherItemRow( + item: $profile.items[index], canMoveUp: index > 0, + canMoveDown: index < profile.items.count - 1, + canDuplicate: profile.items.count < 8, + moveUp: { move(item.id, by: -1) }, + moveDown: { move(item.id, by: 1) }, + duplicate: { duplicate(item.id) }, + remove: { profile.items.removeAll { $0.id == item.id } }) + } + Button { + profile.items.append( + RadialLauncherItem(kind: .application, name: "New action")) + } label: { + Label("Add action", systemImage: "plus") + } + .disabled(profile.items.count >= 8 || !enabled) + Text( + "The wheel supports one to eight applications, files, links, key combinations, media controls, and Edith actions." + ) + .settingsCaption() + } + .disabled(!enabled) + .opacity(enabled ? 1 : 0.5) + .onAppear(perform: load) + .onChange(of: profile) { save() } + .onChange(of: atPointer) { IPC.post(IPC.Name.settingsChanged) } + } + + private func load() { + profile = RadialLauncherProfileStore.decode( + SharedDefaults.store.string(forKey: RadialLauncherPreferenceKeys.profile)) + loaded = true + } + + private func save() { + guard loaded else { return } + SharedDefaults.store.set( + RadialLauncherProfileStore.encode(profile), + forKey: RadialLauncherPreferenceKeys.profile) + IPC.post(IPC.Name.settingsChanged) + } + + private func move(_ id: UUID, by offset: Int) { + guard let source = profile.items.firstIndex(where: { $0.id == id }) else { return } + let destination = source + offset + guard profile.items.indices.contains(destination) else { return } + profile.items.swapAt(source, destination) + } + + private func duplicate(_ id: UUID) { + guard profile.items.count < 8, + let source = profile.items.firstIndex(where: { $0.id == id }) + else { return } + var item = profile.items[source] + item.id = UUID() + item.name = item.displayName + " copy" + profile.items.insert(item, at: source + 1) + } +} + +private struct RadialLauncherItemRow: View { + @Binding var item: RadialLauncherItem + let canMoveUp: Bool + let canMoveDown: Bool + let canDuplicate: Bool + let moveUp: () -> Void + let moveDown: () -> Void + let duplicate: () -> Void + let remove: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: UIScale.pt(8)) { + HStack { + Image(systemName: item.effectiveSymbol) + .frame(width: UIScale.pt(20)) + TextField("Name", text: $item.name) + Picker("Type", selection: $item.kind) { + ForEach(RadialLauncherItemKind.allCases) { kind in + Text(kind.title).tag(kind) + } + } + .labelsHidden() + .frame(width: UIScale.pt(150)) + Button(action: moveUp) { + Image(systemName: "arrow.up") + } + .buttonStyle(.borderless) + .disabled(!canMoveUp) + Button(action: moveDown) { + Image(systemName: "arrow.down") + } + .buttonStyle(.borderless) + .disabled(!canMoveDown) + Button(action: duplicate) { + Image(systemName: "plus.square.on.square") + } + .buttonStyle(.borderless) + .disabled(!canDuplicate) + Button(role: .destructive, action: remove) { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + targetEditor + if !item.isConfigured { + Label( + "Complete this action before it appears in the wheel.", + systemImage: "exclamationmark.triangle" + ) + .font(.system(size: UIScale.pt(11))) + .foregroundStyle(.orange) + } + } + .padding(.vertical, UIScale.pt(4)) + .onChange(of: item.kind) { resetTarget() } + } + + @ViewBuilder + private var targetEditor: some View { + switch item.kind { + case .application, .file: + HStack { + TextField("Path", text: $item.payload) + .textFieldStyle(.roundedBorder) + Button("Choose…", action: choosePath) + } + case .link: + TextField("https://example.com", text: $item.payload) + .textFieldStyle(.roundedBorder) + case .keyCombination: + RadialLauncherShortcutRecorder(item: $item) + case .media: + Picker("Control", selection: $item.payload) { + ForEach(RadialLauncherMediaAction.allCases) { action in + Text(action.title).tag(action.rawValue) + } + } + case .edith: + Picker("Action", selection: $item.payload) { + ForEach(RadialLauncherEdithAction.allCases) { action in + Text(action.title).tag(action.rawValue) + } + } + } + } + + private func resetTarget() { + item.symbol = "" + switch item.kind { + case .application, .file, .link, .keyCombination: + item.payload = "" + case .media: + item.payload = RadialLauncherMediaAction.playPause.rawValue + case .edith: + item.payload = RadialLauncherEdithAction.openPanel.rawValue + } + } + + private func choosePath() { + let panel = NSOpenPanel() + panel.canChooseDirectories = item.kind == .file + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + if item.kind == .application { + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + } + guard panel.runModal() == .OK, let url = panel.url else { return } + item.payload = url.path + if item.name == "New action" || item.name.isEmpty { + item.name = url.deletingPathExtension().lastPathComponent + } + } +} + +private struct RadialLauncherShortcutRecorder: View { + @Binding var item: RadialLauncherItem + @State private var recording = false + @State private var monitor: Any? + + var body: some View { + Button { + recording ? stop() : start() + } label: { + Text(recording ? "Press key combination…" : item.payload.ifEmpty("Record shortcut")) + .font(.system(size: UIScale.pt(12), weight: .medium)) + .kerning(recording ? 0 : 2) + } + .onDisappear { stop() } + } + + private func start() { + recording = true + NSApp.activate(ignoringOtherApps: true) + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + handle(event) + return nil + } + } + + private func stop() { + recording = false + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + } + + private func handle(_ event: NSEvent) { + if event.keyCode == 53 { + stop() + return + } + let flags = event.modifierFlags.intersection([.command, .option, .control, .shift]) + guard !flags.isEmpty else { return } + var modifiers = 0 + var label = "" + if flags.contains(.control) { + modifiers |= controlKey + label += "⌃" + } + if flags.contains(.option) { + modifiers |= optionKey + label += "⌥" + } + if flags.contains(.shift) { + modifiers |= shiftKey + label += "⇧" + } + if flags.contains(.command) { + modifiers |= cmdKey + label += "⌘" + } + item.keyCode = Int(event.keyCode) + item.modifiers = modifiers + item.payload = label + (event.charactersIgnoringModifiers?.uppercased() ?? "?") + stop() + } +} + +private extension String { + func ifEmpty(_ replacement: String) -> String { + isEmpty ? replacement : self + } +} diff --git a/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift b/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift index a8bcda78b..9f7dfcac3 100644 --- a/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift +++ b/Packages/Edith/Sources/Edith/Features/Settings/Views/ShortcutsPane.swift @@ -20,6 +20,9 @@ struct ShortcutsSettingsPane: View { @AppStorage(AppStorageKeys.Presenter.enabled, store: SharedDefaults.store) private var presenterEnabled = false + @AppStorage(RadialLauncherPreferenceKeys.enabled, store: SharedDefaults.store) private + var radialLauncherEnabled = false + @AppStorage(AppStorageKeys.KeystrokeHighlight.enabled, store: SharedDefaults.store) private var keystrokeHighlightEnabled = false @@ -27,7 +30,8 @@ struct ShortcutsSettingsPane: View { ExtensionShortcutVisibility.visible( clipboard: clipboardEnabled, emoji: emojiEnabled, micMute: micMuteEnabled, focusDim: focusDimEnabled, presenter: presenterEnabled, - colorPicker: colorPickerEnabled, keystrokeHighlight: keystrokeHighlightEnabled) + colorPicker: colorPickerEnabled, keystrokeHighlight: keystrokeHighlightEnabled, + radialLauncher: radialLauncherEnabled) } var body: some View { @@ -129,6 +133,10 @@ struct ShortcutsSettingsPane: View { shortcutRow( "Pick a color", subtitle: "Summons the color picker loupe", keyPrefix: "colorPickerHotKey", defaultLabel: "⌃⌥⌘C") + case .radialLauncher: + shortcutRow( + "Radial Launcher", subtitle: "Opens the action wheel at the pointer", + keyPrefix: "radialLauncherHotKey", defaultLabel: "⌥⌘Space") case .keystrokeHighlight: shortcutRow( "Keystroke highlight", subtitle: "Starts or pauses the on-screen keycaps", diff --git a/Packages/Edith/Sources/EdithCLI/CommandTree.swift b/Packages/Edith/Sources/EdithCLI/CommandTree.swift index 24a69be77..dd8242e82 100644 --- a/Packages/Edith/Sources/EdithCLI/CommandTree.swift +++ b/Packages/Edith/Sources/EdithCLI/CommandTree.swift @@ -255,6 +255,8 @@ public enum CommandTree { "ed usage machines disable": Spec(options: ["--json"], arguments: [.machine]), "ed usage machines forget": Spec(options: ["--json"], arguments: [.machine]), "ed usage refresh": Spec(options: ["--json", "--follow", "--machines", "--no-machines"]), + "ed radial profile": Spec(options: common), + "ed radial show": Spec(options: common), "ed system stats": Spec(options: ["--json", "-f", "--follow", "--interval", "--processes"]), "ed system disks": Spec(options: ["--json", "-h", "--help", "--version"]), "ed music": Spec( diff --git a/Packages/Edith/Sources/EdithCLI/Commands/RadialCommands.swift b/Packages/Edith/Sources/EdithCLI/Commands/RadialCommands.swift new file mode 100644 index 000000000..53f469777 --- /dev/null +++ b/Packages/Edith/Sources/EdithCLI/Commands/RadialCommands.swift @@ -0,0 +1,76 @@ +import ArgumentParser +import EdithKit +import Foundation + +struct RadialCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "radial", abstract: "Show and inspect the Radial Launcher.", + subcommands: [RadialShowCommand.self, RadialProfileCommand.self], + defaultSubcommand: RadialProfileCommand.self) +} + +struct RadialShowCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "show", abstract: "Show the launcher at the pointer.") + + @Flag(name: .long, help: "Emit JSON on stdout.") + var json = false + + func run() async throws { + try await execute { + guard + ExtensionRegistry.entry("radialLauncher")?.isEnabled( + in: CLIEnvironment.sharedDefaults) == true + else { + throw CLIFailure.unavailable( + "the Radial Launcher extension is off", + hint: "run `ed extensions enable radialLauncher`") + } + try AppBridge.requireHelper("the Radial Launcher") + AppBridge.post(IPC.Name.requestRadialLauncher) + if json { + CLIOut.json(.object(["shown": .bool(true)])) + } else { + CLIOut.out("radial launcher shown") + } + } + } +} + +struct RadialProfileCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "profile", abstract: "Print the active launcher profile.") + + @Flag(name: .long, help: "Emit JSON on stdout.") + var json = false + + func run() async throws { + let profile = RadialLauncherProfileStore.decode( + CLIEnvironment.sharedDefaults.string(forKey: RadialLauncherPreferenceKeys.profile)) + guard json else { + let rows = profile.items.enumerated().map { index, item in + [String(index + 1), item.kind.title, item.name, item.payload] + } + CLIOut.out( + profile.name + "\n" + + TextTable.render( + headers: ["#", "TYPE", "NAME", "TARGET"], rows: rows)) + return + } + CLIOut.json( + .object([ + "id": .string(profile.id.uuidString), "name": .string(profile.name), + "items": .array( + profile.items.map { item in + .object([ + "id": .string(item.id.uuidString), + "kind": .string(item.kind.rawValue), "name": .string(item.name), + "symbol": .string(item.effectiveSymbol), + "payload": .string(item.payload), "keyCode": .int(item.keyCode), + "modifiers": .int(item.modifiers), + "configured": .bool(item.isConfigured), + ]) + }), + ])) + } +} diff --git a/Packages/Edith/Sources/EdithCLI/Commands/Root.swift b/Packages/Edith/Sources/EdithCLI/Commands/Root.swift index 25cb942d7..689d9f01b 100644 --- a/Packages/Edith/Sources/EdithCLI/Commands/Root.swift +++ b/Packages/Edith/Sources/EdithCLI/Commands/Root.swift @@ -59,6 +59,7 @@ public struct EdRoot: AsyncParsableCommand { MusicCommand.self, CalendarCommand.self, PresenterCommand.self, + RadialCommand.self, HerdrCommand.self, ClipboardCommand.self, AttentionCommand.self, diff --git a/Packages/Edith/Sources/EdithCLI/Guide.swift b/Packages/Edith/Sources/EdithCLI/Guide.swift index 1eeda5995..758c6885a 100644 --- a/Packages/Edith/Sources/EdithCLI/Guide.swift +++ b/Packages/Edith/Sources/EdithCLI/Guide.swift @@ -41,6 +41,8 @@ public enum Guide { ed permissions ls every macOS permission Edith uses ed color pick open Edith's system colour sampler ed color copy 1 --format hex + ed radial profile --json inspect the active Radial Launcher wheel + ed radial show open the wheel at the pointer ed emoji pick open Edith's emoji picker ed usage sources the agents that produced your usage history ed usage export branded PNG cards for sharing your activity diff --git a/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift b/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift index 31959a06b..3ec6dd477 100644 --- a/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift +++ b/Packages/Edith/Sources/EdithCore/ExtensionLifecycle.swift @@ -965,6 +965,42 @@ public enum ExtensionLifecycleCatalog { "history", "Read sampled colors", "Confirm the color history repository responds.", "ed color ls --json") ]), + descriptor( + "radialLauncher", "Open apps, files, links, shortcuts and controls around the pointer.", + workflows: [ + instruction( + "launch", "Summon the wheel", + "Press the global shortcut, point at a slice and release or click.", + "ed radial show"), + instruction( + "configure", "Build your wheel", + "Choose up to eight actions in the extension settings."), + ], + prerequisites: [ + instruction( + "accessibility", "Allow key combinations", + "Accessibility is only needed by slices that send a key combination.", + "ed permissions request accessibility") + ], + examples: [ + "ed extensions enable radialLauncher", "ed radial show", + "ed radial profile --json", + ], + docs: [ + documentation("guide", "Radial Launcher guide", "docs/cli/radial/README.md") + ], + recovery: [ + instruction( + "doctor", "Check launcher readiness", + "Inspect the helper, shortcut and optional Accessibility grant.", + "ed extensions doctor radialLauncher --json") + ], + verification: [ + instruction( + "profile", "Read the active profile", + "Confirm the wheel has at least one configured action.", + "ed radial profile --json") + ]), descriptor( "homebrew", "Install, upgrade and remove Homebrew packages from one client.", workflows: [ diff --git a/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift index 68692cdb6..2ac7464a0 100644 --- a/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithCore/ExtensionRegistry.swift @@ -70,6 +70,15 @@ public enum ExtensionMarketplaceFilter { } } +public enum RadialLauncherPreferenceKeys { + public static let enabled = "radialLauncherEnabled" + public static let profile = "radialLauncherProfile" + public static let atPointer = "radialLauncherAtPointer" + public static let hotKeyCode = "radialLauncherHotKeyCode" + public static let hotKeyMods = "radialLauncherHotKeyMods" + public static let hotKeyLabel = "radialLauncherHotKeyLabel" +} + public struct ExtensionRegistryEntry: Identifiable, Equatable, Sendable { public let id: String public let title: String @@ -225,6 +234,12 @@ public enum ExtensionRegistry { subtitle: "Blurs sensitive numbers while sharing your screen.", symbolName: "theatermasks.fill", suite: .desk, host: .bar, featured: false, defaultsKey: "presenterEnabled", requiredCapabilities: [.screenShareDetection]), + ExtensionRegistryEntry( + id: "radialLauncher", title: "Radial Launcher", + subtitle: "Your favorite actions in a pointer-centered wheel.", + symbolName: "circle.hexagongrid.fill", suite: .desk, host: .bar, featured: true, + defaultsKey: RadialLauncherPreferenceKeys.enabled, + requiredCapabilities: [.globalShortcuts], optionalCapabilities: [.mediaControls]), ExtensionRegistryEntry( id: "music", title: "Music", subtitle: "Plays your local music folder, with media keys and a player bar.", diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift index ce41f7e64..ebfc7d7c0 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/AppServices.swift @@ -1,3 +1,4 @@ +import AppKit import EdithKit import Foundation @@ -20,6 +21,7 @@ final class AppServices { private(set) var lidAwake: LidAwakeEngine? private(set) var systemStats: SystemStatsStatusItem? private(set) var attention: AttentionTrackingService? + private(set) var radialLauncher: RadialLauncherService? private let startup = StartupCoordinator() private let lidAwakeRestorationGate = LidAwakeRestorationGate() private let lidAwakeOrphanRestorer: @MainActor @Sendable () async -> LidAwakeOutcome @@ -111,6 +113,7 @@ final class AppServices { shutDownEmojiRuntime() keystrokeHighlight?.shutdown() if #available(macOS 14.4, *) { MixerEngine.shared.shutdown() } + radialLauncher?.shutdown() await lidAwake?.shutdownForTermination() await lidAwakeRestorationGate.wait() } @@ -315,6 +318,38 @@ final class AppServices { notchShelf?.attachUsage(usage) notchShelf?.attachCalendar(calendar) notchShelf?.attachColorPicker(colorPicker) + + let radialOn = + ExtensionRegistry.entry("radialLauncher")?.isEnabled(in: SharedDefaults.store) ?? false + if radialOn, radialLauncher == nil { + radialLauncher = RadialLauncherService { [weak self] action in + self?.performRadialAction(action) + } + } + if !radialOn, let launcher = radialLauncher { + launcher.shutdown() + radialLauncher = nil + } + radialLauncher?.syncSettings() + } + + private func performRadialAction(_ action: RadialLauncherEdithAction) { + switch action { + case .openPanel: + showPanel() + case .clipboard: + guard clipboard != nil else { return NSSound.beep() } + ClipboardPanel.shared.toggle() + case .colorPicker: + guard let colorPicker else { return NSSound.beep() } + colorPicker.pick() + case .micMute: + guard let micMute else { return NSSound.beep() } + micMute.toggle() + case .cleanKeys: + guard let system else { return NSSound.beep() } + system.beginCleaning() + } } private func shutDownEmojiRuntime() { diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift index e78b27c19..2d20352ed 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/EdithHelperApp.swift @@ -308,8 +308,9 @@ struct EdithApp { } -private func dispatchGlobalHotKey(_ id: UInt32) { - if let action = GlobalHotKey.actions[id] { +private func dispatchGlobalHotKey(_ id: UInt32, released: Bool) { + let action = released ? GlobalHotKey.releaseActions[id] : GlobalHotKey.actions[id] + if let action { DispatchQueue.main.async { PerformanceTrace.measure(.input, "helper.globalHotKey") { action() } } @@ -319,12 +320,17 @@ private func dispatchGlobalHotKey(_ id: UInt32) { enum GlobalHotKey { fileprivate static var refs: [UInt32: EventHotKeyRef] = [:] fileprivate static var actions: [UInt32: () -> Void] = [:] + fileprivate static var releaseActions: [UInt32: () -> Void] = [:] private static var handlerInstalled = false private static func installHandlerOnce() { guard !handlerInstalled else { return } - var eventType = EventTypeSpec( - eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + var eventTypes = [ + EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), + EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)), + ] InstallEventHandler( GetApplicationEventTarget(), { _, event, _ in @@ -334,21 +340,29 @@ enum GlobalHotKey { event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout.size, nil, &hotKeyID) - dispatchGlobalHotKey(hotKeyID.id) + dispatchGlobalHotKey( + hotKeyID.id, released: GetEventKind(event) == UInt32(kEventHotKeyReleased)) return noErr - }, 1, &eventType, nil, nil) + }, eventTypes.count, &eventTypes, nil, nil) handlerInstalled = true } - static func set(id: UInt32, keyCode: Int, modifiers: Int, action: @escaping () -> Void) { + @discardableResult + static func set( + id: UInt32, keyCode: Int, modifiers: Int, action: @escaping () -> Void, + release: (() -> Void)? = nil + ) -> Bool { installHandlerOnce() clear(id: id) - actions[id] = action let hotKeyID = EventHotKeyID(signature: OSType(0x4544_4954), id: id) var ref: EventHotKeyRef? - RegisterEventHotKey( + let status = RegisterEventHotKey( UInt32(keyCode), UInt32(modifiers), hotKeyID, GetApplicationEventTarget(), 0, &ref) + guard status == noErr, let ref else { return false } refs[id] = ref + actions[id] = action + releaseActions[id] = release + return true } static func clear(id: UInt32) { @@ -356,6 +370,7 @@ enum GlobalHotKey { UnregisterEventHotKey(ref) } actions.removeValue(forKey: id) + releaseActions.removeValue(forKey: id) } } diff --git a/Packages/Edith/Sources/EdithHelper/Core/Application/HotKeyRegistrar.swift b/Packages/Edith/Sources/EdithHelper/Core/Application/HotKeyRegistrar.swift index 3d00d808a..14f474c6d 100644 --- a/Packages/Edith/Sources/EdithHelper/Core/Application/HotKeyRegistrar.swift +++ b/Packages/Edith/Sources/EdithHelper/Core/Application/HotKeyRegistrar.swift @@ -4,9 +4,11 @@ import Foundation @MainActor enum HotKeyRegistrar { + private static var releases: [String: () -> Void] = [:] private static var actions: [String: () -> Void] = [:] - static func install(_ id: String, action: @escaping () -> Void) { + static func install(_ id: String, release: (() -> Void)? = nil, action: @escaping () -> Void) { + releases[id] = release actions[id] = action apply(id) } @@ -19,7 +21,7 @@ enum HotKeyRegistrar { } GlobalHotKey.set( id: binding.carbonID, keyCode: binding.code(), modifiers: binding.mods(), - action: action) + action: action, release: releases[id]) } static func applyAll() { diff --git a/Packages/Edith/Sources/EdithHelper/Features/RadialLauncher/RadialLauncherService.swift b/Packages/Edith/Sources/EdithHelper/Features/RadialLauncher/RadialLauncherService.swift new file mode 100644 index 000000000..8badd5076 --- /dev/null +++ b/Packages/Edith/Sources/EdithHelper/Features/RadialLauncher/RadialLauncherService.swift @@ -0,0 +1,400 @@ +import AppKit +import Carbon.HIToolbox +import CoreGraphics +import EdithKit +import IOKit.hidsystem +import SwiftUI + +@MainActor +final class RadialLauncherService: ObservableObject { + @Published private(set) var profile = RadialLauncherProfile.starter + @Published private(set) var highlightedIndex: Int? + @Published private(set) var visible = false + + private let executeEdith: (RadialLauncherEdithAction) -> Void + private var panel: RadialLauncherPanel? + private var monitors: [Any] = [] + private var requestObserver: NSObjectProtocol? + private var center = CGPoint.zero + + var items: [RadialLauncherItem] { + profile.items.filter(\.isConfigured) + } + + init(executeEdith: @escaping (RadialLauncherEdithAction) -> Void) { + self.executeEdith = executeEdith + requestObserver = IPC.observe(IPC.Name.requestRadialLauncher) { [weak self] in + self?.show() + } + syncSettings() + } + + func syncSettings() { + profile = RadialLauncherProfileStore.decode( + SharedDefaults.store.string(forKey: RadialLauncherPreferenceKeys.profile)) + guard HotKeyCatalog.binding(HotKeyCatalog.radialLauncher)?.isEnabled() == true else { + shutdownRuntime() + return + } + HotKeyRegistrar.install( + HotKeyCatalog.radialLauncher, release: { [weak self] in self?.shortcutReleased() }, + action: { [weak self] in self?.show() }) + } + + func shutdown() { + shutdownRuntime() + if let requestObserver { IPC.stopObserving(requestObserver) } + requestObserver = nil + } + + func show() { + guard HotKeyCatalog.binding(HotKeyCatalog.radialLauncher)?.isEnabled() == true else { + return + } + guard !items.isEmpty else { + NSSound.beep() + return + } + if visible { + dismiss() + return + } + highlightedIndex = nil + let pointer = NSEvent.mouseLocation + let screen = NSScreen.screens.first { $0.frame.contains(pointer) } ?? NSScreen.main + guard let screen else { return } + let usePointer = + SharedDefaults.store.object( + forKey: RadialLauncherPreferenceKeys.atPointer) as? Bool ?? true + let wanted = + usePointer + ? pointer : CGPoint(x: screen.visibleFrame.midX, y: screen.visibleFrame.midY) + center = clamped(wanted, to: screen.visibleFrame) + let panel = ensurePanel() + let size = RadialLauncherLayout.panelSize + panel.setFrame( + NSRect( + x: center.x - size / 2, y: center.y - size / 2, + width: size, height: size), + display: true) + visible = true + panel.orderFrontRegardless() + panel.makeKey() + installMonitors() + } + + func dismiss() { + visible = false + highlightedIndex = nil + panel?.orderOut(nil) + removeMonitors() + } + + func select(_ index: Int) { + guard items.indices.contains(index) else { return } + let item = items[index] + dismiss() + execute(item) + } + + private func shortcutReleased() { + guard visible, let highlightedIndex else { return } + select(highlightedIndex) + } + + private func ensurePanel() -> RadialLauncherPanel { + if let panel { return panel } + let panel = RadialLauncherPanel( + contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + panel.level = .statusBar + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient] + panel.isReleasedWhenClosed = false + panel.contentView = NSHostingView(rootView: RadialLauncherPresentation(service: self)) + self.panel = panel + return panel + } + + private func installMonitors() { + removeMonitors() + let events: NSEvent.EventTypeMask = [.mouseMoved, .leftMouseDragged, .rightMouseDragged] + if let local = NSEvent.addLocalMonitorForEvents( + matching: events, + handler: { [weak self] event in + self?.updateHighlight(NSEvent.mouseLocation) + return event + }) + { + monitors.append(local) + } + if let global = NSEvent.addGlobalMonitorForEvents( + matching: events, + handler: { [weak self] _ in + let point = NSEvent.mouseLocation + Task { @MainActor in self?.updateHighlight(point) } + }) + { + monitors.append(global) + } + if let mouse = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown], + handler: { [weak self] _ in + let point = NSEvent.mouseLocation + Task { @MainActor in + guard self?.panel?.frame.contains(point) == false else { return } + self?.dismiss() + } + }) + { + monitors.append(mouse) + } + if let keys = NSEvent.addLocalMonitorForEvents( + matching: .keyDown, + handler: { [weak self] event in + let keyCode = event.keyCode + let characters = event.charactersIgnoringModifiers + let handled = MainActor.assumeIsolated { + self?.handleKey(keyCode: keyCode, characters: characters) == true + } + return handled ? nil : event + }) + { + monitors.append(keys) + } + } + + private func removeMonitors() { + monitors.forEach(NSEvent.removeMonitor) + monitors.removeAll() + } + + private func handleKey(keyCode: UInt16, characters: String?) -> Bool { + if keyCode == 53 { + dismiss() + return true + } + if keyCode == 36, let highlightedIndex { + select(highlightedIndex) + return true + } + if let digit = Int(characters ?? ""), + digit > 0, items.indices.contains(digit - 1) + { + select(digit - 1) + return true + } + if keyCode == 123 || keyCode == 126 { + moveSelection(-1) + return true + } + if keyCode == 124 || keyCode == 125 { + moveSelection(1) + return true + } + return false + } + + private func moveSelection(_ amount: Int) { + let count = items.count + guard count > 0 else { return } + highlightedIndex = ((highlightedIndex ?? (amount > 0 ? -1 : 0)) + amount + count) % count + } + + private func updateHighlight(_ point: CGPoint) { + highlightedIndex = RadialLauncherSelection.index( + dx: point.x - center.x, dy: point.y - center.y, + itemCount: items.count, deadZone: RadialLauncherLayout.deadZone) + } + + private func clamped(_ point: CGPoint, to frame: CGRect) -> CGPoint { + let inset = RadialLauncherLayout.panelSize / 2 + return CGPoint( + x: min(max(point.x, frame.minX + inset), frame.maxX - inset), + y: min(max(point.y, frame.minY + inset), frame.maxY - inset)) + } + + private func execute(_ item: RadialLauncherItem) { + switch item.kind { + case .application, .file: + let path = NSString(string: item.payload).expandingTildeInPath + guard !path.isEmpty else { return NSSound.beep() } + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + case .link: + guard let url = URL(string: item.payload), + ["http", "https"].contains(url.scheme?.lowercased() ?? "") + else { return NSSound.beep() } + NSWorkspace.shared.open(url) + case .keyCombination: + postKey(code: item.keyCode, modifiers: item.modifiers) + case .media: + guard let action = RadialLauncherMediaAction(rawValue: item.payload) else { + return NSSound.beep() + } + postMedia(action) + case .edith: + guard let action = RadialLauncherEdithAction(rawValue: item.payload) else { + return NSSound.beep() + } + executeEdith(action) + } + } + + private func postKey(code: Int, modifiers: Int) { + guard AXIsProcessTrusted() else { + NSSound.beep() + IPC.post(IPC.Name.permissionHintDue) + return + } + guard let source = CGEventSource(stateID: .hidSystemState), + let down = CGEvent( + keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: true), + let up = CGEvent( + keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: false) + else { return } + var flags = CGEventFlags() + if modifiers & cmdKey != 0 { flags.insert(.maskCommand) } + if modifiers & optionKey != 0 { flags.insert(.maskAlternate) } + if modifiers & controlKey != 0 { flags.insert(.maskControl) } + if modifiers & shiftKey != 0 { flags.insert(.maskShift) } + down.flags = flags + up.flags = flags + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + } + + private func postMedia(_ action: RadialLauncherMediaAction) { + let key: Int32 + switch action { + case .playPause: key = NX_KEYTYPE_PLAY + case .next: key = NX_KEYTYPE_NEXT + case .previous: key = NX_KEYTYPE_PREVIOUS + } + for state in [0xA, 0xB] { + let event = NSEvent.otherEvent( + with: .systemDefined, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: 0, context: nil, subtype: 8, + data1: Int((key << 16) | Int32(state << 8)), data2: -1) + event?.cgEvent?.post(tap: .cghidEventTap) + } + } + + private func shutdownRuntime() { + HotKeyRegistrar.clear(HotKeyCatalog.radialLauncher) + dismiss() + panel?.contentView = nil + panel = nil + } +} + +private final class RadialLauncherPanel: NSPanel { + override var canBecomeKey: Bool { true } +} + +private enum RadialLauncherLayout { + static let panelSize: CGFloat = 440 + static let radius: CGFloat = 145 + static let deadZone: CGFloat = 46 +} + +private struct RadialLauncherPresentation: View { + @ObservedObject var service: RadialLauncherService + + var body: some View { + RadialLauncherWheel( + profile: service.profile, highlightedIndex: service.highlightedIndex, + select: service.select) + } +} + +struct RadialLauncherWheel: View { + let profile: RadialLauncherProfile + let highlightedIndex: Int? + let select: (Int) -> Void + + private var items: [RadialLauncherItem] { profile.items.filter(\.isConfigured) } + + var body: some View { + ZStack { + Circle() + .fill(.ultraThinMaterial) + .frame(width: 350, height: 350) + .overlay(Circle().stroke(.white.opacity(0.16))) + .shadow(color: .black.opacity(0.3), radius: 28, y: 12) + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + itemButton(item, index: index) + .offset(offset(index, count: items.count)) + } + VStack(spacing: 4) { + Image(systemName: "circle.hexagongrid.fill") + .font(.system(size: 25, weight: .semibold)) + Text(profile.name) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + Text("esc to close") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + } + .frame(width: 104, height: 104) + .background(.regularMaterial, in: Circle()) + .overlay(Circle().stroke(.white.opacity(0.12))) + } + .frame(width: RadialLauncherLayout.panelSize, height: RadialLauncherLayout.panelSize) + } + + private func itemButton(_ item: RadialLauncherItem, index: Int) -> some View { + Button { + select(index) + } label: { + VStack(spacing: 6) { + itemIcon(item) + .font(.system(size: 25, weight: .semibold)) + .frame(width: 30, height: 30) + Text(item.displayName) + .font(.system(size: 10, weight: .semibold)) + .lineLimit(2) + .multilineTextAlignment(.center) + } + .foregroundStyle(highlightedIndex == index ? Color.white : Color.primary) + .frame(width: 86, height: 86) + .background( + highlightedIndex == index ? Color.accentColor : Color.clear, + in: Circle() + ) + .background(.regularMaterial, in: Circle()) + .overlay( + Circle().stroke(.white.opacity(highlightedIndex == index ? 0.5 : 0.12)) + ) + .scaleEffect(highlightedIndex == index ? 1.1 : 1) + .shadow( + color: highlightedIndex == index + ? Color.accentColor.opacity(0.45) : .black.opacity(0.18), + radius: highlightedIndex == index ? 15 : 7, y: 4 + ) + .animation(.snappy(duration: 0.16), value: highlightedIndex) + } + .buttonStyle(.edith(.borderless)) + .accessibilityLabel("\(index + 1), \(item.displayName)") + } + + @ViewBuilder + private func itemIcon(_ item: RadialLauncherItem) -> some View { + if item.kind == .application || item.kind == .file { + let path = NSString(string: item.payload).expandingTildeInPath + Image(nsImage: NSWorkspace.shared.icon(forFile: path)) + .resizable() + .scaledToFit() + } else { + Image(systemName: item.effectiveSymbol) + } + } + + private func offset(_ index: Int, count: Int) -> CGSize { + let angle = -Double.pi / 2 + 2 * Double.pi * Double(index) / Double(count) + return CGSize( + width: cos(angle) * RadialLauncherLayout.radius, + height: sin(angle) * RadialLauncherLayout.radius) + } +} diff --git a/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift b/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift index c0b0e423a..329a66ad3 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Backup/SettingsBackup.swift @@ -808,6 +808,9 @@ final class SettingsBackup { AppStorageKeys.ColorPicker.profile, AppStorageKeys.ColorPicker.historySize, "colorPickerHotKeyCode", "colorPickerHotKeyMods", "colorPickerHotKeyLabel", + RadialLauncherPreferenceKeys.enabled, RadialLauncherPreferenceKeys.profile, + RadialLauncherPreferenceKeys.atPointer, RadialLauncherPreferenceKeys.hotKeyCode, + RadialLauncherPreferenceKeys.hotKeyMods, RadialLauncherPreferenceKeys.hotKeyLabel, AppStorageKeys.Emoji.enabled, AppStorageKeys.Emoji.popupAt, AppStorageKeys.Emoji.skinTone, AppStorageKeys.Emoji.frequentCount, AppStorageKeys.Emoji.usage, AppStorageKeys.Emoji.hotKeyCode, @@ -934,6 +937,9 @@ final class SettingsBackup { AppStorageKeys.ColorPicker.profile, AppStorageKeys.ColorPicker.historySize, "colorPickerHotKeyCode", "colorPickerHotKeyMods", "colorPickerHotKeyLabel", + RadialLauncherPreferenceKeys.enabled, RadialLauncherPreferenceKeys.profile, + RadialLauncherPreferenceKeys.atPointer, RadialLauncherPreferenceKeys.hotKeyCode, + RadialLauncherPreferenceKeys.hotKeyMods, RadialLauncherPreferenceKeys.hotKeyLabel, AppStorageKeys.Emoji.enabled, AppStorageKeys.Emoji.popupAt, AppStorageKeys.Emoji.skinTone, AppStorageKeys.Emoji.frequentCount, AppStorageKeys.Emoji.usage, AppStorageKeys.Emoji.hotKeyCode, diff --git a/Packages/Edith/Sources/EdithKit/Core/HotKeys/HotKeyCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/HotKeys/HotKeyCatalog.swift index e7110d754..08c7ae601 100644 --- a/Packages/Edith/Sources/EdithKit/Core/HotKeys/HotKeyCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/HotKeys/HotKeyCatalog.swift @@ -67,6 +67,7 @@ public enum HotKeyCatalog { public static let micMute = "micMute" public static let presenter = "presenter" public static let emoji = "emoji" + public static let radialLauncher = "radialLauncher" public static let keystrokeHighlight = "keystrokeHighlight" public static let bindings: [HotKeyBinding] = [ @@ -104,6 +105,12 @@ public enum HotKeyCatalog { modsKey: "presenterHotKeyMods", labelKey: "presenterHotKeyLabel", defaultCode: kVK_ANSI_P, defaultMods: cmdKey | optionKey | shiftKey, defaultLabel: "⇧⌥⌘P", abilityID: "presenter"), + HotKeyBinding( + id: radialLauncher, title: "Radial launcher", carbonID: 50, + codeKey: RadialLauncherPreferenceKeys.hotKeyCode, + modsKey: RadialLauncherPreferenceKeys.hotKeyMods, + labelKey: RadialLauncherPreferenceKeys.hotKeyLabel, defaultCode: kVK_Space, + defaultMods: cmdKey | optionKey, defaultLabel: "⌥⌘Space", abilityID: "radialLauncher"), HotKeyBinding( id: keystrokeHighlight, title: "Keystroke highlight", carbonID: 9, codeKey: AppStorageKeys.KeystrokeHighlight.hotKeyCode, diff --git a/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift b/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift index 93bd26304..18a5092a7 100644 --- a/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift +++ b/Packages/Edith/Sources/EdithKit/Core/IPC/IPC.swift @@ -36,6 +36,8 @@ public enum IPC { public static let clipboardChanged = IPC.scopedName("com.pulkit.edith.clipboardChanged") public static let requestColorPick = IPC.scopedName( "com.pulkit.edith.requestColorPick") + public static let requestRadialLauncher = IPC.scopedName( + "com.pulkit.edith.requestRadialLauncher") public static let requestEmojiPanel = IPC.scopedName( "com.pulkit.edith.requestEmojiPanel") public static let requestEmojiInsert = IPC.scopedName( diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift index 15970623b..989403cd4 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/ConfigCatalog.swift @@ -52,7 +52,7 @@ public enum ConfigCatalog { "machines", "herdr", "quinjet", "companion", "finder", "system", "homebrew", "cleaner", "music", "calendar", - "clipboard", "keystrokes", + "clipboard", "radial", "keystrokes", "notch", "focusdim", "presenter", "colorpicker", "emoji", "micmute", "backup", "permissions", "terminal", ] @@ -63,7 +63,7 @@ public enum ConfigCatalog { + finder + system + homebrew + cleaner + music + calendar + clipboard + keystrokeHighlight + notch + focusDim + presenter + colorPicker + emoji - + micMute + + micMute + radialLauncher + backup + permissions + terminal public static var keys: [String] { settings.map(\.key) } @@ -980,6 +980,28 @@ public enum ConfigCatalog { summary: "Printable label for the mic mute shortcut."), ] + private static let radialLauncher: [SettingDefinition] = [ + SettingDefinition( + RadialLauncherPreferenceKeys.enabled, .bool, group: "radial", + summary: "Radial Launcher extension: pointer-centered action wheel.", + fallback: .bool(false)), + SettingDefinition( + RadialLauncherPreferenceKeys.profile, .string, group: "radial", + summary: "JSON document for the active launcher profile."), + SettingDefinition( + RadialLauncherPreferenceKeys.atPointer, .bool, group: "radial", + summary: "Center the launcher on the pointer.", fallback: .bool(true)), + SettingDefinition( + RadialLauncherPreferenceKeys.hotKeyCode, .int, group: "radial", + summary: "Virtual key code of the launcher shortcut.", fallback: .int(49)), + SettingDefinition( + RadialLauncherPreferenceKeys.hotKeyMods, .int, group: "radial", + summary: "Carbon modifier mask of the launcher shortcut.", fallback: .int(2304)), + SettingDefinition( + RadialLauncherPreferenceKeys.hotKeyLabel, .string, group: "radial", + summary: "Printable label for the launcher shortcut.", fallback: .string("⌥⌘Space")), + ] + private static let terminal: [SettingDefinition] = [ SettingDefinition( CompletionScripts.autoRefreshKey, .bool, group: "terminal", diff --git a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift index 2a943a4a3..30f782b3a 100644 --- a/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift +++ b/Packages/Edith/Sources/EdithKit/Core/Operations/UserOperationCatalog.swift @@ -103,6 +103,9 @@ public enum UserOperationCatalog { + LidAwakeOperation.allCases.map { RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) } + + RadialLauncherOperation.allCases.map { + RegisteredUserOperation(descriptor: $0.descriptor, exposure: $0.interfaceExposure) + } private static let featureRegistrations: [RegisteredUserOperation] = UsageProjectOperation.allCases.map { @@ -947,6 +950,17 @@ private extension LidAwakeOperation { } } +private extension RadialLauncherOperation { + var interfaceExposure: UserOperationExposure { + switch self { + case .show: + userInterface("Radial Launcher", "show the action wheel") + case .profile: + userInterface("Radial Launcher settings", "inspect the active profile") + } + } +} + private extension UsageProjectOperation { var interfaceExposure: UserOperationExposure { switch self { diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift index df3ee6525..838aa9645 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/ExtensionRegistry.swift @@ -21,6 +21,7 @@ public typealias ExtensionLifecycleState = EdithCore.ExtensionLifecycleState public typealias PlatformCapability = EdithCore.PlatformCapability public typealias PlatformCapabilityState = EdithCore.PlatformCapabilityState public typealias PlatformCapabilities = EdithCore.PlatformCapabilities +public typealias RadialLauncherPreferenceKeys = EdithCore.RadialLauncherPreferenceKeys public enum ExtensionPermission: String, CaseIterable, Hashable, Sendable { case calendar @@ -142,7 +143,7 @@ public extension ExtensionRegistryEntry { case "system": [.accessibility, .inputMonitoring] case "notchShelf": [.bluetooth, .camera, .automation] case "audioMixer": [.applicationAudio] - case "clipboard", "emoji": [.accessibility] + case "clipboard", "emoji", "radialLauncher": [.accessibility] default: [] } } diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/FeatureGates.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/FeatureGates.swift index 6e4b29f80..08339b3fd 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/FeatureGates.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/FeatureGates.swift @@ -33,13 +33,14 @@ public enum ExtensionShortcut: String, CaseIterable, Hashable, Sendable { case focusDim case presenter case colorPicker + case radialLauncher case keystrokeHighlight } public enum ExtensionShortcutVisibility { public static func visible( clipboard: Bool, emoji: Bool, micMute: Bool, focusDim: Bool, presenter: Bool, - colorPicker: Bool, keystrokeHighlight: Bool + colorPicker: Bool, keystrokeHighlight: Bool, radialLauncher: Bool = false ) -> [ExtensionShortcut] { let states: [(ExtensionShortcut, Bool)] = [ (.clipboard, clipboard), @@ -48,6 +49,7 @@ public enum ExtensionShortcutVisibility { (.focusDim, focusDim), (.presenter, presenter), (.colorPicker, colorPicker), + (.radialLauncher, radialLauncher), (.keystrokeHighlight, keystrokeHighlight), ] return states.compactMap { shortcut, enabled in enabled ? shortcut : nil } diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/RadialLauncher.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/RadialLauncher.swift new file mode 100644 index 000000000..12ee72671 --- /dev/null +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Models/RadialLauncher.swift @@ -0,0 +1,234 @@ +import Carbon.HIToolbox +import EdithCore +import Foundation + +public enum RadialLauncherItemKind: String, Codable, CaseIterable, Identifiable, Sendable { + case application + case file + case link + case keyCombination + case media + case edith + + public var id: String { rawValue } + + public var title: String { + switch self { + case .application: "Application" + case .file: "File or folder" + case .link: "Link" + case .keyCombination: "Key combination" + case .media: "Media control" + case .edith: "Edith action" + } + } + + public var defaultSymbol: String { + switch self { + case .application: "app.fill" + case .file: "folder.fill" + case .link: "link" + case .keyCombination: "command" + case .media: "playpause.fill" + case .edith: "sparkles" + } + } +} + +public enum RadialLauncherMediaAction: String, Codable, CaseIterable, Identifiable, Sendable { + case playPause + case next + case previous + + public var id: String { rawValue } + + public var title: String { + switch self { + case .playPause: "Play or pause" + case .next: "Next track" + case .previous: "Previous track" + } + } + + public var symbol: String { + switch self { + case .playPause: "playpause.fill" + case .next: "forward.fill" + case .previous: "backward.fill" + } + } +} + +public enum RadialLauncherEdithAction: String, Codable, CaseIterable, Identifiable, Sendable { + case openPanel + case clipboard + case colorPicker + case micMute + case cleanKeys + + public var id: String { rawValue } + + public var title: String { + switch self { + case .openPanel: "Open Edith panel" + case .clipboard: "Open clipboard" + case .colorPicker: "Pick a color" + case .micMute: "Toggle mic mute" + case .cleanKeys: "Clean keyboard" + } + } + + public var symbol: String { + switch self { + case .openPanel: "eyeglasses" + case .clipboard: "doc.on.clipboard" + case .colorPicker: "eyedropper" + case .micMute: "mic.slash.fill" + case .cleanKeys: "keyboard" + } + } +} + +public struct RadialLauncherItem: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var kind: RadialLauncherItemKind + public var name: String + public var symbol: String + public var payload: String + public var keyCode: Int + public var modifiers: Int + + public init( + id: UUID = UUID(), kind: RadialLauncherItemKind, name: String, symbol: String = "", + payload: String = "", keyCode: Int = 0, modifiers: Int = 0 + ) { + self.id = id + self.kind = kind + self.name = name + self.symbol = symbol + self.payload = payload + self.keyCode = keyCode + self.modifiers = modifiers + } + + public var effectiveSymbol: String { + if !symbol.isEmpty { return symbol } + if kind == .media, let action = RadialLauncherMediaAction(rawValue: payload) { + return action.symbol + } + if kind == .edith, let action = RadialLauncherEdithAction(rawValue: payload) { + return action.symbol + } + return kind.defaultSymbol + } + + public var displayName: String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? kind.title : trimmed + } + + public var isConfigured: Bool { + switch kind { + case .application, .file: + return !payload.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .link: + guard let url = URL(string: payload), let scheme = url.scheme?.lowercased() else { + return false + } + return scheme == "http" || scheme == "https" + case .keyCombination: + return modifiers != 0 && !payload.isEmpty + case .media: + return RadialLauncherMediaAction(rawValue: payload) != nil + case .edith: + return RadialLauncherEdithAction(rawValue: payload) != nil + } + } +} + +public struct RadialLauncherProfile: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var items: [RadialLauncherItem] + + public init(id: UUID = UUID(), name: String, items: [RadialLauncherItem]) { + self.id = id + self.name = name + self.items = Array(items.prefix(8)) + } + + public static var starter: Self { + RadialLauncherProfile( + name: "Favorites", + items: [ + RadialLauncherItem( + kind: .application, name: "Safari", + payload: "/System/Applications/Safari.app"), + RadialLauncherItem( + kind: .file, name: "Downloads", payload: "~/Downloads"), + RadialLauncherItem( + kind: .link, name: "Edith", + payload: "https://github.com/pulkitxm/edith"), + RadialLauncherItem( + kind: .keyCombination, name: "Screenshot", symbol: "camera.viewfinder", + payload: "⇧⌘4", keyCode: kVK_ANSI_4, modifiers: cmdKey | shiftKey), + RadialLauncherItem( + kind: .media, name: "Play or pause", + payload: RadialLauncherMediaAction.playPause.rawValue), + RadialLauncherItem( + kind: .edith, name: "Edith panel", + payload: RadialLauncherEdithAction.openPanel.rawValue), + ]) + } +} + +public enum RadialLauncherSelection { + public static func index( + dx: Double, dy: Double, itemCount: Int, deadZone: Double + ) -> Int? { + guard itemCount > 0, hypot(dx, dy) >= deadZone else { return nil } + let step = 2 * Double.pi / Double(itemCount) + var index = Int(round((Double.pi / 2 - atan2(dy, dx)) / step)) % itemCount + if index < 0 { index += itemCount } + return index + } +} + +public enum RadialLauncherProfileStore { + public static func decode(_ value: String?) -> RadialLauncherProfile { + guard let value, let profile = decodeIfValid(value) else { return .starter } + return profile + } + + public static func decodeIfValid(_ value: String) -> RadialLauncherProfile? { + guard let data = value.data(using: .utf8), + let profile = try? JSONDecoder().decode(RadialLauncherProfile.self, from: data) + else { return nil } + return RadialLauncherProfile(id: profile.id, name: profile.name, items: profile.items) + } + + public static func encode(_ profile: RadialLauncherProfile) -> String { + guard let data = try? JSONEncoder().encode(profile) else { return "" } + return String(decoding: data, as: UTF8.self) + } +} + +public enum RadialLauncherOperation: String, CaseIterable, Sendable { + case show + case profile + + public var descriptor: UserOperationDescriptor { + switch self { + case .show: + UserOperationDescriptor( + id: UserOperationID(rawValue: "radial.show"), + summary: "Show the Radial Launcher at the pointer.", + cli: ["radial", "show"], effect: .interactive) + case .profile: + UserOperationDescriptor( + id: UserOperationID(rawValue: "radial.profile"), + summary: "Read the active Radial Launcher profile.", + cli: ["radial", "profile"], effect: .read) + } + } +} diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift index 8bb0f668c..db46a84d9 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLifecycleProbe.swift @@ -220,6 +220,8 @@ public struct ExtensionLifecycleProbe: Sendable { requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "colorPicker": Policy( requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), + "radialLauncher": Policy( + requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "emoji": Policy( requiresHelper: true, requiresMachine: false, toolRule: .all, adapter: true), "homebrew": Policy( diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift index cec0e0b1a..87ec77428 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionLiveAdapters.swift @@ -68,7 +68,8 @@ public enum ExtensionLiveAdapters { "usage", "quinjet", "plugins", "appMaintenance", "homebrew", "cleaner", "system", "keepAwake", "lidAwake", "systemStats", "micMute", "clipboard", "emoji", "colorPicker", "keystrokeHighlight", - "focusDim", "presenter", "music", "downloads", "notchShelf", "audioMixer", "calendar", + "focusDim", "presenter", "radialLauncher", "music", "downloads", "notchShelf", "audioMixer", + "calendar", "attention", "seoAudit", ] @@ -121,11 +122,32 @@ public enum ExtensionLiveAdapters { case "focusDim": await focusDimReadiness(defaults: defaults) case "presenter": presenterReadiness(defaults: defaults) case "colorPicker": await colorPickerReadiness(defaults: defaults) + case "radialLauncher": radialLauncherReadiness(defaults: defaults) case "emoji": emojiReadiness(defaults: defaults) default: nil } } + static func radialLauncherReadiness(defaults: UserDefaults) -> ExtensionAdapterReadiness { + let storedProfile = defaults.string(forKey: RadialLauncherPreferenceKeys.profile) + let decodedProfile = storedProfile.flatMap(RadialLauncherProfileStore.decodeIfValid) + let profile = decodedProfile ?? .starter + let profileIsValid = storedProfile == nil || decodedProfile != nil + let storedCode = defaults.object(forKey: RadialLauncherPreferenceKeys.hotKeyCode) + let storedModifiers = defaults.object(forKey: RadialLauncherPreferenceKeys.hotKeyMods) + let codeIsValid = + storedCode == nil || (storedCode as? Int).map { (0...127).contains($0) } == true + let modifiersAreValid = + storedModifiers == nil || (storedModifiers as? Int).map { $0 != 0 } == true + let count = profile.items.filter(\.isConfigured).count + return ExtensionAdapterFacts( + configured: profileIsValid && codeIsValid && modifiersAreValid, contentCount: count, + readyDetail: "Configured radial actions: \(count).", + setupDetail: "The stored profile or global shortcut is invalid.", + emptyDetail: "Add at least one complete action to the radial profile." + ).readiness + } + static func attentionReadiness( settings: AttentionSettings = AttentionRepository().loadSettings() ) -> ExtensionAdapterReadiness { diff --git a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift index 924abbc58..222bb0194 100644 --- a/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift +++ b/Packages/Edith/Sources/EdithKit/Features/Extensions/Services/ExtensionMutationCenter.swift @@ -97,6 +97,7 @@ public enum ExtensionDetailRoute: String, CaseIterable, Sendable { case focusDim case presenter case colorPicker + case radialLauncher case emoji } diff --git a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift index d8d395bf8..1ab01dcd7 100644 --- a/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithCoreTests/ExtensionRegistryTests.swift @@ -17,6 +17,7 @@ import Testing "appMaintenance", "homebrew", "cleaner", "system", "keepAwake", "lidAwake", "systemStats", "micMute", "clipboard", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "presenter", + "radialLauncher", "music", "downloads", "notchShelf", "audioMixer", "calendar", "database", "attention", "seoAudit", ]) diff --git a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift index d5d848bf8..82ad35ba8 100644 --- a/Packages/Edith/Tests/EdithTests/CLIContractTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIContractTests.swift @@ -22,6 +22,8 @@ enum JSONContract { JSONCase("ed guide", ["guide", "--json"]), JSONCase("ed version", ["version", "--json"]), JSONCase("ed status", ["status", "--json"]), + JSONCase("ed radial profile", ["radial", "profile", "--json"]), + JSONCase("ed radial show", ["radial", "show", "--json"]), JSONCase("ed brew status", ["brew", "status", "--json"]), JSONCase("ed brew ls", ["brew", "ls", "--json"]), JSONCase("ed brew search", ["brew", "search", "edith", "--json"]), diff --git a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift index 55dabb8bb..9afaa3604 100644 --- a/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift +++ b/Packages/Edith/Tests/EdithTests/CLIShapeTests.swift @@ -238,6 +238,7 @@ enum CommandCrawler { "ed machines terminal", "ed lid-awake", "ed attention", "ed attention categories", "ed attention focus", + "ed radial", "ed quinjet", "ed database", "ed database connections", "ed database saved-queries", "ed database mutations", "ed database operations", "ed database mcp", diff --git a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift index 25a5fce67..1684cf6dc 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionLifecycleProbeTests.swift @@ -93,6 +93,9 @@ import EdithDatabase id: "presenter", helper: true, machine: false, toolRule: .all, adapter: true, requiredTools: [], optionalTools: []), + MatrixRow( + id: "radialLauncher", helper: true, machine: false, toolRule: .all, adapter: true, + requiredTools: [], optionalTools: []), MatrixRow( id: "music", helper: true, machine: false, toolRule: .all, adapter: true, diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift index 8f9c05ea5..4d717ea13 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRegistryTests.swift @@ -26,6 +26,7 @@ import Testing "keystrokeHighlightEnabled", "focusDimEnabled", "presenterEnabled", + "radialLauncherEnabled", "tabMusicEnabled", "downloadsEnabled", "notchShelfEnabled", @@ -48,6 +49,7 @@ import Testing "appMaintenance", "homebrew", "cleaner", "system", "keepAwake", "lidAwake", "systemStats", "micMute", "clipboard", "emoji", "colorPicker", "keystrokeHighlight", "focusDim", "presenter", + "radialLauncher", "music", "downloads", "notchShelf", "audioMixer", "calendar", "database", "attention", "seoAudit", ]) @@ -202,7 +204,7 @@ import Testing #expect( featuredIdentifiers == [ "usage", "herdr", "quinjet", "appMaintenance", "system", "keepAwake", "clipboard", - "keystrokeHighlight", "notchShelf", "database", "attention", + "keystrokeHighlight", "radialLauncher", "notchShelf", "database", "attention", ]) } @@ -343,6 +345,7 @@ import Testing "keystrokeHighlight": [.inputMonitoring], "focusDim": [.screenRecording], "presenter": [.screenRecording], + "radialLauncher": [], "music": [], "downloads": [], "notchShelf": [], @@ -372,6 +375,7 @@ import Testing "keystrokeHighlight": [], "focusDim": [], "presenter": [], + "radialLauncher": [.accessibility], "music": [], "downloads": [], "notchShelf": [.bluetooth, .camera, .automation], diff --git a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift index 41812fc37..5cfae6c03 100644 --- a/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift +++ b/Packages/Edith/Tests/EdithTests/ExtensionRuntimeStateTests.swift @@ -274,6 +274,7 @@ import Testing ("focusDim", "FocusDimRows", "enabled", "FocusDimRows.swift"), ("presenter", "PresenterRows", "presenterEnabled", "PresenterRows.swift"), ("colorPicker", "ColorPickerRows", "colorPickerEnabled", "ColorPickerRows.swift"), + ("radialLauncher", "RadialLauncherRows", "enabled", "RadialLauncherRows.swift"), ("emoji", "EmojiRows", "emojiEnabled", "EmojiRows.swift"), ("homebrew", "HomebrewRows", "enabled", "ExtensionsPane.swift"), ("cleaner", "CleanerRows", "enabled", "ExtensionsPane.swift"), diff --git a/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift b/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift index 5827a9d34..8a18e4994 100644 --- a/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift +++ b/Packages/Edith/Tests/EdithTests/FeatureGatesTests.swift @@ -77,7 +77,7 @@ import Testing #expect( ExtensionShortcutVisibility.visible( clipboard: true, emoji: true, micMute: true, focusDim: true, presenter: true, - colorPicker: true, keystrokeHighlight: true) + colorPicker: true, keystrokeHighlight: true, radialLauncher: true) == ExtensionShortcut.allCases) } diff --git a/Packages/Edith/Tests/EdithTests/HotKeyCatalogTests.swift b/Packages/Edith/Tests/EdithTests/HotKeyCatalogTests.swift index 74f9de201..6495b7a76 100644 --- a/Packages/Edith/Tests/EdithTests/HotKeyCatalogTests.swift +++ b/Packages/Edith/Tests/EdithTests/HotKeyCatalogTests.swift @@ -18,7 +18,7 @@ import Testing let carbonIDs = HotKeyCatalog.bindings.map(\.carbonID) #expect(Set(ids).count == ids.count) #expect(Set(carbonIDs).count == carbonIDs.count) - #expect(ids.count == 8) + #expect(ids.count == 9) } @Test func everyBindingNamesItsOwnDefaultsKeys() { diff --git a/Packages/Edith/Tests/EdithTests/RadialLauncherRenderTests.swift b/Packages/Edith/Tests/EdithTests/RadialLauncherRenderTests.swift new file mode 100644 index 000000000..0249d430c --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/RadialLauncherRenderTests.swift @@ -0,0 +1,48 @@ +import AppKit +import SwiftUI +import Testing + +@testable import Edith +@testable import EdithHelper +@testable import EdithKit + +@Suite @MainActor struct RadialLauncherRenderTests { + @Test func wheelRendersWithSyntheticActions() throws { + _ = TestWindowHost.application + let defaults = SharedDefaults.store + let key = RadialLauncherPreferenceKeys.enabled + let previous = defaults.object(forKey: key) + defaults.set(true, forKey: key) + defer { defaults.set(previous, forKey: key) } + let profile = RadialLauncherProfile( + name: "Daily tools", + items: [ + RadialLauncherItem(kind: .link, name: "Reading", payload: "https://example.com"), + RadialLauncherItem( + kind: .media, name: "Play or pause", + payload: RadialLauncherMediaAction.playPause.rawValue), + RadialLauncherItem( + kind: .edith, name: "Edith panel", + payload: RadialLauncherEdithAction.openPanel.rawValue), + RadialLauncherItem( + kind: .media, name: "Next track", + payload: RadialLauncherMediaAction.next.rawValue), + ]) + let view = RadialLauncherWheel(profile: profile, highlightedIndex: 1, select: { _ in }) + .environment(\.automaticViewActionsEnabled, false) + .environment(\.colorScheme, .light) + .frame(width: 440, height: 440) + let host = NSHostingView(rootView: view) + host.frame = NSRect(x: 0, y: 0, width: 440, height: 440) + host.layoutSubtreeIfNeeded() + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + #expect(bitmap.pixelsWide >= 440) + #expect(data.count > 15_000) + if let directory = ProcessInfo.processInfo.environment["EDITH_RENDER_DUMP"] { + try data.write( + to: URL(fileURLWithPath: directory).appendingPathComponent("radial-launcher.png")) + } + } +} diff --git a/Packages/Edith/Tests/EdithTests/RadialLauncherTests.swift b/Packages/Edith/Tests/EdithTests/RadialLauncherTests.swift new file mode 100644 index 000000000..d77427c7f --- /dev/null +++ b/Packages/Edith/Tests/EdithTests/RadialLauncherTests.swift @@ -0,0 +1,148 @@ +import Carbon.HIToolbox +import Testing + +@testable import EdithCLI +import EdithCore +@testable import EdithKit + +@Suite struct RadialLauncherTests { + @Test func starterProfileCoversEveryActionKind() { + let profile = RadialLauncherProfile.starter + + #expect(profile.items.map(\.kind) == RadialLauncherItemKind.allCases) + #expect(profile.items.allSatisfy { $0.isConfigured }) + #expect(profile.items.count <= 8) + } + + @Test func profileStorageRoundTripsAndCapsTheWheel() { + var profile = RadialLauncherProfile.starter + profile.name = "Work" + profile.items = (0..<10).map { + RadialLauncherItem( + kind: .link, name: "Link \($0)", payload: "https://example.com/\($0)") + } + + let decoded = RadialLauncherProfileStore.decode(RadialLauncherProfileStore.encode(profile)) + + #expect(decoded.name == "Work") + #expect(decoded.items.count == 8) + #expect(decoded.items == Array(profile.items.prefix(8))) + } + + @Test func invalidProfileFallsBackToStarter() { + #expect( + RadialLauncherProfileStore.decode(nil).items.map(\.kind) + == RadialLauncherItemKind.allCases) + #expect( + RadialLauncherProfileStore.decode("not json").items.map(\.kind) + == RadialLauncherItemKind.allCases) + #expect(RadialLauncherProfileStore.decodeIfValid("not json") == nil) + } + + @Test func itemValidationMatchesExecutionRequirements() { + #expect(!RadialLauncherItem(kind: .application, name: "Empty").isConfigured) + #expect(RadialLauncherItem(kind: .file, name: "Home", payload: "~").isConfigured) + #expect( + RadialLauncherItem(kind: .link, name: "Web", payload: "https://edith.app").isConfigured) + #expect( + !RadialLauncherItem(kind: .link, name: "Local", payload: "file:///tmp").isConfigured) + #expect( + RadialLauncherItem( + kind: .keyCombination, name: "Copy", payload: "⌘C", keyCode: kVK_ANSI_C, + modifiers: cmdKey + ).isConfigured) + #expect( + RadialLauncherItem( + kind: .media, name: "Next", payload: RadialLauncherMediaAction.next.rawValue + ).isConfigured) + #expect( + RadialLauncherItem( + kind: .edith, name: "Panel", + payload: RadialLauncherEdithAction.openPanel.rawValue + ).isConfigured) + } + + @Test func emptyNamesUseTheActionKind() { + let item = RadialLauncherItem(kind: .media, name: " ", payload: "next") + + #expect(item.displayName == "Media control") + #expect(item.effectiveSymbol == "forward.fill") + } + + @Test func selectionStartsAtTopAndMovesClockwise() { + #expect(RadialLauncherSelection.index(dx: 0, dy: 10, itemCount: 4, deadZone: 20) == nil) + #expect(RadialLauncherSelection.index(dx: 0, dy: 100, itemCount: 4, deadZone: 20) == 0) + #expect(RadialLauncherSelection.index(dx: 100, dy: 0, itemCount: 4, deadZone: 20) == 1) + #expect(RadialLauncherSelection.index(dx: 0, dy: -100, itemCount: 4, deadZone: 20) == 2) + #expect(RadialLauncherSelection.index(dx: -100, dy: 0, itemCount: 4, deadZone: 20) == 3) + #expect(RadialLauncherSelection.index(dx: 100, dy: 0, itemCount: 0, deadZone: 20) == nil) + } + + @Test func extensionSurfacesStayRegistered() throws { + let entry = try #require(ExtensionRegistry.entries.first { $0.id == "radialLauncher" }) + let lifecycle = try #require(entry.lifecycle) + + #expect(entry.defaultsKey == RadialLauncherPreferenceKeys.enabled) + #expect(entry.requiredCapabilities == [.globalShortcuts]) + #expect(entry.optionalCapabilities == [.mediaControls]) + #expect(entry.optionalPermissions == [.accessibility]) + #expect(lifecycle.cliExamples.contains("ed radial show")) + #expect(lifecycle.documentation.map(\.path).contains("docs/cli/radial/README.md")) + } + + @Test func configurationKeysShareTheRadialGroup() { + let keys = [ + RadialLauncherPreferenceKeys.enabled, RadialLauncherPreferenceKeys.profile, + RadialLauncherPreferenceKeys.atPointer, RadialLauncherPreferenceKeys.hotKeyCode, + RadialLauncherPreferenceKeys.hotKeyMods, RadialLauncherPreferenceKeys.hotKeyLabel, + ] + + #expect(keys.allSatisfy { ConfigCatalog.definition(for: $0)?.group == "radial" }) + #expect( + ConfigCatalog.definition(for: RadialLauncherPreferenceKeys.atPointer)?.fallback + == .bool(true)) + #expect( + ConfigCatalog.definition(for: RadialLauncherPreferenceKeys.hotKeyCode)?.fallback + == .int(kVK_Space)) + } + + @Test func liveReadinessChecksProfileContentAndShortcutStorage() { + let suite = "test.radial-launcher.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + #expect( + ExtensionLiveAdapters.radialLauncherReadiness(defaults: defaults) + == .ready("Configured radial actions: 6.")) + defaults.set( + RadialLauncherProfileStore.encode(RadialLauncherProfile(name: "Empty", items: [])), + forKey: RadialLauncherPreferenceKeys.profile) + #expect( + ExtensionLiveAdapters.radialLauncherReadiness(defaults: defaults) + == .empty("Add at least one complete action to the radial profile.")) + defaults.set("invalid", forKey: RadialLauncherPreferenceKeys.profile) + #expect( + ExtensionLiveAdapters.radialLauncherReadiness(defaults: defaults) + == .needsSetup("The stored profile or global shortcut is invalid.")) + defaults.removeObject(forKey: RadialLauncherPreferenceKeys.profile) + defaults.set(0, forKey: RadialLauncherPreferenceKeys.hotKeyMods) + #expect( + ExtensionLiveAdapters.radialLauncherReadiness(defaults: defaults) + == .needsSetup("The stored profile or global shortcut is invalid.")) + } + + @Test func operationsAndCommandsStayInParity() throws { + for operation in RadialLauncherOperation.allCases { + #expect( + UserOperationCatalog.descriptor(id: operation.descriptor.id) == operation.descriptor + ) + #expect( + UserOperationCatalog.descriptor(cli: operation.descriptor.cli) + == operation.descriptor) + } + + #expect(try EdRoot.parseAsRoot(["radial", "show", "--json"]) is RadialShowCommand) + #expect(try EdRoot.parseAsRoot(["radial", "profile", "--json"]) is RadialProfileCommand) + #expect(try EdRoot.parseAsRoot(["radial"]) is RadialProfileCommand) + } +} diff --git a/docs/cli/README.md b/docs/cli/README.md index 03b326e08..fdce33508 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -58,6 +58,7 @@ report still exits 0, so read `verified`, `state.phase`, `state.runtimePhase`, | [`ed music`](./music/README.md) | Playback control and the local music library | | [`ed calendar`](./calendar/README.md) | Your agenda | | [`ed presenter`](./presenter/README.md) | Manual presenter mode at runtime | +| [`ed radial`](./radial/README.md) | Pointer-centered launcher actions | | [`ed herdr`](./herdr/README.md) | Live Herdr sessions on this Mac and your SSH machines | | [`ed quinjet`](./quinjet/README.md) | Discover, inspect and launch Quinjet review workspaces locally or on a configured machine | | [`ed database`](./database/README.md) | Saved database connections and capability reports through the authenticated local broker | diff --git a/docs/cli/extensions/README.md b/docs/cli/extensions/README.md index bd9c2c93b..ff70cd983 100644 --- a/docs/cli/extensions/README.md +++ b/docs/cli/extensions/README.md @@ -33,10 +33,10 @@ enables immediately and reports missing grants in plain text or JSON. | `ed extensions enable ` | Turns one on, and names on stderr any required permission still missing | | `ed extensions disable ` | Turns one off | | `ed extensions info ` | Describes one: name, summary, key, group, state, permissions | -| `ed extensions status [id]` | Summarises readiness for one extension or all twenty-two | +| `ed extensions status [id]` | Summarises readiness for one extension or every registered extension | | `ed extensions setup ` | Enables one and reports the setup that remains | | `ed extensions verify ` | Runs every readiness check for one extension | -| `ed extensions doctor [id]` | Diagnoses one extension or all twenty-two, with recovery commands | +| `ed extensions doctor [id]` | Diagnoses one extension or every registered extension, with recovery commands | The Extensions pane and each extension settings modal use these same typed read operations. Marketplace browsing maps to `ls`, opening a modal maps to `info`, @@ -52,7 +52,7 @@ operations as their command-line equivalents. ## The registry `ExtensionRegistry.entries` in EdithCore is the single list every command here -walks, and its order is the order `ls` prints. Twenty-two entries, in this order: +walks, and its order is the order `ls` prints. Every current entry appears here: | ID | Name | Group | What it does | | --- | --- | --- | --- | @@ -79,10 +79,11 @@ walks, and its order is the order `ls` prints. Twenty-two entries, in this order | `presenter` | Presenter | Utilities | Blurs sensitive numbers while sharing your screen | | `emoji` | Emoji Picker | Utilities | Every macOS emoji on a hotkey | | `colorPicker` | Color Picker | Utilities | System loupe on a hotkey, sampled color to your clipboard | +| `radialLauncher` | Radial Launcher | Utilities | Pointer-centered wheel for apps, files, links, shortcuts, controls, and Edith actions | -The same twenty-two, with what each one is made of. `Key` is the preference the app +The same registry, with what each entry is made of. `Key` is the preference the app reads, and the key `ed config` writes for the same feature. `Featured` marks the -eleven the welcome tour shows before you ask it for all of them. +entries the welcome tour shows before you ask it for all of them. | ID | Key | Featured | Required permissions | Optional permissions | Required tools | Optional tools | | --- | --- | --- | --- | --- | --- | --- | @@ -108,6 +109,7 @@ eleven the welcome tour shows before you ask it for all of them. | `presenter` | `presenterEnabled` | no | `screenRecording` | none | none | none | | `emoji` | `emojiEnabled` | no | none | `accessibility` | none | none | | `colorPicker` | `colorPickerEnabled` | no | `screenRecording` | none | none | none | +| `radialLauncher` | `radialLauncherEnabled` | yes | none | `accessibility` | none | none | The JSON form also exposes the platform capability registry. Capabilities are not permission ids. They say which implementation an extension requires from @@ -138,6 +140,7 @@ the current platform, and which missing implementations merely degrade it: | `presenter` | `screenShareDetection` | none | | `emoji` | `emojiInsertion` | `globalShortcuts` | | `colorPicker` | `screenColorSampling` | `globalShortcuts` | +| `radialLauncher` | `globalShortcuts` | `mediaControls` | An id is matched exactly and case-insensitively against the `ID` column first, then against the `Key` column, so `ed extensions info clipboard`, diff --git a/docs/cli/radial/README.md b/docs/cli/radial/README.md new file mode 100644 index 000000000..80bde0bc6 --- /dev/null +++ b/docs/cli/radial/README.md @@ -0,0 +1,74 @@ +# Radial Launcher + +Radial Launcher puts a configurable wheel around the pointer so frequently used +actions stay one gesture away. Its active profile holds up to eight actions in +a stable clockwise order. + +## Set up the wheel + +Enable Radial Launcher from Settings > Extensions, then open its settings. The +starter profile demonstrates every supported action type. Each action can be +edited, duplicated, reordered, or removed, and incomplete actions stay out of +the runtime wheel until their target is valid. + +| Action | Target | +| --- | --- | +| Application | A macOS application selected from disk | +| File or folder | Any local file or directory | +| Link | An `http` or `https` URL | +| Key combination | A recorded shortcut with one or more modifiers | +| Media control | Play or pause, next track, or previous track | +| Edith action | Open Edith, Clipboard, Color Picker, Mic Mute, or Clean Keys | + +The default shortcut is Option-Command-Space. It can be changed in the +extension settings or the Shortcuts pane. Center on pointer can be turned off +to place the wheel in the middle of the active display instead. + +## Use the wheel + +Press the global shortcut, move toward a slice, and release to execute it. A +quick press without a direction leaves the wheel open. While it is open, you +can click a slice, press its number, use arrow keys and Return, or press Escape +to close it. Clicking outside also closes the wheel. + +The wheel is clamped to the visible part of the current display, so it remains +usable near screen edges and across multiple displays. + +## Command line + +The CLI shows the same active profile and can summon the same helper runtime. + +```text +ed radial profile [--json] +ed radial show [--json] +``` + +A bare `ed radial` runs `profile`. Plain output prints the profile name and its +wheel order. JSON includes each item type, name, target, symbol, key code, +modifier mask, and whether the item is fully configured. + +`ed radial show` asks the running menu bar helper to open the wheel at the +pointer. It exits 4 when the extension is off or Edith is not running. + +Use the configuration catalog to inspect the extension without opening Edith: + +```text +ed config ls --group radial +ed extensions status radialLauncher +ed extensions doctor radialLauncher --json +``` + +## Permissions and recovery + +Key-combination slices need Accessibility permission. Other slice types and the +global shortcut do not. If a key combination beeps instead of running, grant +Accessibility in System Settings and run `ed permissions refresh`. + +If the shortcut does not open the wheel, check that Radial Launcher is enabled +and Edith is running. A shortcut already owned by another app must be changed in +settings. `ed extensions doctor radialLauncher --json` distinguishes a stopped +helper, an invalid shortcut or profile, and a profile with no complete actions. + +- [`ed extensions`](../extensions/README.md) +- [`ed config`](../config/README.md) +- [All command groups](../README.md)