Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,7 @@ private struct ExtensionDetailRows: View {
case .focusDim: FocusDimRows()
case .presenter: PresenterRows()
case .colorPicker: ColorPickerRows()
case .radialLauncher: RadialLauncherRows()
case .emoji: EmojiRows()
}
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ 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

private var extensionShortcuts: [ExtensionShortcut] {
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 {
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions Packages/Edith/Sources/EdithCLI/CommandTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading