Skip to content
Open
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
12 changes: 8 additions & 4 deletions Leader Key.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
423632282D6A806700878D92 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 423632272D6A806700878D92 /* Theme.swift */; };
42454DDB2D71CB39004E1374 /* ConfigValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42454DDA2D71CB39004E1374 /* ConfigValidator.swift */; };
42454DDD2D71CBAB004E1374 /* ConfigValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */; };
EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */; };
425495402D75EFAD0020300E /* ForTheHorde.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4254953F2D75EFAD0020300E /* ForTheHorde.swift */; };
426E625B2D2E6A98009FD2F2 /* CommandRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */; };
4279AFED2C6A175500952A83 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 4279AFEC2C6A175500952A83 /* LaunchAtLogin */; };
Expand All @@ -26,7 +25,6 @@
427C181A2BD3123C00955B98 /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = 427C18192BD3123C00955B98 /* Defaults */; };
427C181C2BD314B500955B98 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C181B2BD314B500955B98 /* Constants.swift */; };
427C18202BD31C3D00955B98 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C181F2BD31C3D00955B98 /* AppDelegate.swift */; };
73192AF63CAF425397D7C0D1 /* URLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */; };
427C18232BD31DF100955B98 /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = 427C18222BD31DF100955B98 /* Settings */; };
427C18282BD31E2E00955B98 /* GeneralPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18242BD31E2E00955B98 /* GeneralPane.swift */; };
427C18292BD31E2E00955B98 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18252BD31E2E00955B98 /* MainWindow.swift */; };
Expand Down Expand Up @@ -55,6 +53,9 @@
6D9B9C012DBA000000000001 /* ConfigOutlineEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */; };
6D9B9C042DBA000000000002 /* KeyCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C032DBA000000000002 /* KeyCapture.swift */; };
6D9B9C062DBA000000000003 /* ConfigEditorShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C052DBA000000000003 /* ConfigEditorShared.swift */; };
73192AF63CAF425397D7C0D1 /* URLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */; };
ABCD12342E850000DEADBEEF /* InputPromptWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD12332E850000DEADBEEF /* InputPromptWindow.swift */; };
EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand All @@ -76,7 +77,6 @@
423632272D6A806700878D92 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
42454DDA2D71CB39004E1374 /* ConfigValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidator.swift; sourceTree = "<group>"; };
42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidatorTests.swift; sourceTree = "<group>"; };
EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeTests.swift; sourceTree = "<group>"; };
4254953F2D75EFAD0020300E /* ForTheHorde.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForTheHorde.swift; sourceTree = "<group>"; };
426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunner.swift; sourceTree = "<group>"; };
4279AFEA2C6A08B100952A83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = "Leader Key/Support/Info.plist"; sourceTree = SOURCE_ROOT; };
Expand All @@ -88,7 +88,6 @@
427C17FC2BD311B500955B98 /* UserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConfigTests.swift; sourceTree = "<group>"; };
427C181B2BD314B500955B98 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
427C181F2BD31C3D00955B98 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeHandler.swift; sourceTree = "<group>"; };
427C18242BD31E2E00955B98 /* GeneralPane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralPane.swift; sourceTree = "<group>"; };
427C18252BD31E2E00955B98 /* MainWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = "<group>"; };
427C18262BD31E2E00955B98 /* StatusItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = "<group>"; };
Expand All @@ -114,6 +113,9 @@
6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigOutlineEditorView.swift; sourceTree = "<group>"; };
6D9B9C032DBA000000000002 /* KeyCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCapture.swift; sourceTree = "<group>"; };
6D9B9C052DBA000000000003 /* ConfigEditorShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigEditorShared.swift; sourceTree = "<group>"; };
73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeHandler.swift; sourceTree = "<group>"; };
ABCD12332E850000DEADBEEF /* InputPromptWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputPromptWindow.swift; sourceTree = "<group>"; };
EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -195,6 +197,7 @@
427C184C2BD65C5C00955B98 /* Defaults.swift */,
42DFCD712D5B7D46002EA111 /* Events.swift */,
42F4CDD02D48C51F00D0DD76 /* Extensions.swift */,
ABCD12332E850000DEADBEEF /* InputPromptWindow.swift */,
42F4CDC82D458FF700D0DD76 /* MainMenu.swift */,
427C18252BD31E2E00955B98 /* MainWindow.swift */,
427C18532BD6E59300955B98 /* NSWindow+Animations.swift */,
Expand Down Expand Up @@ -423,6 +426,7 @@
42DFCD722D5B7D48002EA111 /* Events.swift in Sources */,
427C182D2BD31F9500955B98 /* Settings.swift in Sources */,
426E625B2D2E6A98009FD2F2 /* CommandRunner.swift in Sources */,
ABCD12342E850000DEADBEEF /* InputPromptWindow.swift in Sources */,
427C18382BD3262100955B98 /* VisualEffectBackground.swift in Sources */,
42B21FBC2D67566100F4A2C7 /* Alerts.swift in Sources */,
427C183B2BD329F900955B98 /* UserConfig.swift in Sources */,
Expand Down
52 changes: 44 additions & 8 deletions Leader Key/Controller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Controller {
var window: MainWindow!
var cheatsheetWindow: NSWindow!
private var cheatsheetTimer: Timer?
private var inputPromptWindow: InputPromptWindow?

private var cancellables = Set<AnyCancellable>()

Expand Down Expand Up @@ -160,7 +161,11 @@ class Controller {
switch hit {
case .action(let action):
if execute {
if let mods = modifiers, isInStickyMode(mods) {
if action.requiresInput {
hide {
self.showInputPrompt(for: action)
}
} else if let mods = modifiers, isInStickyMode(mods) {
runAction(action)
} else {
hide {
Expand Down Expand Up @@ -300,17 +305,21 @@ class Controller {
}

private func runAction(_ action: Action) {
runActionWithValue(action, value: action.value)
}

private func runActionWithValue(_ action: Action, value: String) {
switch action.type {
case .application:
NSWorkspace.shared.openApplication(
at: URL(fileURLWithPath: action.value),
at: URL(fileURLWithPath: value),
configuration: NSWorkspace.OpenConfiguration())
case .url:
openURL(action)
openURLWithValue(action, value: value)
case .command:
CommandRunner.run(action.value)
CommandRunner.run(value)
case .folder:
let path: String = (action.value as NSString).expandingTildeInPath
let path: String = (value as NSString).expandingTildeInPath
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
default:
print("\(action.type) unknown")
Expand All @@ -321,22 +330,49 @@ class Controller {
}
}

private func showInputPrompt(for action: Action) {
guard let promptMessage = action.prompt else { return }

inputPromptWindow?.close()
inputPromptWindow = nil

inputPromptWindow = InputPromptWindow(
label: action.displayName,
prompt: promptMessage,
placeholder: "",
onSubmit: { [weak self] input in
guard let self = self else { return }
let substitutedValue = action.valueWithInput(input)
self.runActionWithValue(action, value: substitutedValue)
self.inputPromptWindow = nil
},
onCancel: { [weak self] in
self?.inputPromptWindow = nil
}
)
inputPromptWindow?.showAndFocus()
}

private func clear() {
userState.clear()
}

private func openURL(_ action: Action) {
guard let url = URL(string: action.value) else {
openURLWithValue(action, value: action.value)
}

private func openURLWithValue(_ action: Action, value: String) {
guard let url = URL(string: value) else {
showAlert(
title: "Invalid URL", message: "Failed to parse URL: \(action.value)")
title: "Invalid URL", message: "Failed to parse URL: \(value)")
return
}

guard let scheme = url.scheme else {
showAlert(
title: "Invalid URL",
message:
"URL is missing protocol (e.g. https://, raycast://): \(action.value)"
"URL is missing protocol (e.g. https://, raycast://): \(value)"
)
return
}
Expand Down
143 changes: 143 additions & 0 deletions Leader Key/InputPromptWindow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import Cocoa
import SwiftUI

class InputPromptWindow: NSPanel {
private var textField: NSTextField!
private var onSubmit: ((String) -> Void)?
private var onCancel: (() -> Void)?
private var previousApp: NSRunningApplication?

init(label: String, prompt: String, placeholder: String = "", onSubmit: @escaping (String) -> Void, onCancel: @escaping () -> Void) {
self.onSubmit = onSubmit
self.onCancel = onCancel

super.init(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 56),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)

self.isFloatingPanel = true
self.level = .screenSaver // Topmost level
self.isOpaque = false
self.backgroundColor = .clear
self.hasShadow = true
self.isReleasedWhenClosed = false
self.hidesOnDeactivate = false
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]

setupUI(label: label, prompt: prompt, placeholder: placeholder)
positionWindow()

self.delegate = self
}

override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }

private func positionWindow() {
guard let screen = NSScreen.main else {
center()
return
}

let screenFrame = screen.visibleFrame
let windowFrame = self.frame
let x = screenFrame.midX - windowFrame.width / 2
let y = screenFrame.maxY - 200 - windowFrame.height // 200pt from top

self.setFrameOrigin(NSPoint(x: x, y: y))
}

private func setupUI(label: String, prompt: String, placeholder: String) {
let container = NSView(frame: NSRect(x: 0, y: 0, width: 500, height: 56))
container.wantsLayer = true
container.layer?.backgroundColor = NSColor.clear.cgColor

let visualEffect = NSVisualEffectView(frame: container.bounds)
visualEffect.material = .hudWindow
visualEffect.state = .active
visualEffect.wantsLayer = true
visualEffect.layer?.cornerRadius = 12
visualEffect.layer?.masksToBounds = true
container.addSubview(visualEffect)

let actionLabel = NSTextField(labelWithString: label)
actionLabel.font = NSFont.systemFont(ofSize: 11, weight: .medium)
actionLabel.textColor = NSColor.tertiaryLabelColor
actionLabel.alignment = .right
actionLabel.frame = NSRect(x: 350, y: 36, width: 134, height: 14)
actionLabel.lineBreakMode = .byTruncatingTail
visualEffect.addSubview(actionLabel)

textField = NSTextField(string: "")
textField.placeholderString = prompt
textField.font = NSFont.systemFont(ofSize: 24, weight: .light)
textField.isBordered = false
textField.drawsBackground = false
textField.focusRingType = .none
textField.frame = NSRect(x: 16, y: 10, width: 468, height: 32)
textField.delegate = self
visualEffect.addSubview(textField)

self.contentView = container
}

func showAndFocus() {
previousApp = NSWorkspace.shared.frontmostApplication

positionWindow()
makeKeyAndOrderFront(nil)
orderFrontRegardless() // Force to front
textField.becomeFirstResponder()
textField.selectText(nil)

NSApp.activate(ignoringOtherApps: true)
}

@objc private func submitAction() {
let value = textField.stringValue
close()
onSubmit?(value)
}

@objc private func cancelAction() {
close()
restorePreviousApp()
onCancel?()
}

private func restorePreviousApp() {
previousApp?.activate()
}
}

extension InputPromptWindow: NSTextFieldDelegate {
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
submitAction()
return true
} else if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
cancelAction()
return true
}
return false
}
}

extension InputPromptWindow: NSWindowDelegate {
func windowDidResignKey(_ notification: Notification) {
cancelAction()
}
}

extension Action {
var requiresInput: Bool {
return prompt != nil && !prompt!.isEmpty
}

func valueWithInput(_ input: String) -> String {
return value.replacingOccurrences(of: "{input}", with: input)
}
}
16 changes: 12 additions & 4 deletions Leader Key/UserConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ struct Action: Item, Codable, Equatable {
var label: String?
var value: String
var iconPath: String?
var prompt: String?

var displayName: String {
guard let labelValue = label else { return bestGuessDisplayName }
Expand All @@ -447,18 +448,19 @@ struct Action: Item, Codable, Equatable {
return value
}
}
private enum CodingKeys: String, CodingKey { case key, type, label, value, iconPath }
private enum CodingKeys: String, CodingKey { case key, type, label, value, iconPath, prompt }

init(
uiid: UUID = UUID(), key: String?, type: Type, label: String? = nil, value: String,
iconPath: String? = nil
iconPath: String? = nil, prompt: String? = nil
) {
self.uiid = uiid
self.key = key
self.type = type
self.label = label
self.value = value
self.iconPath = iconPath
self.prompt = prompt
}

init(from decoder: Decoder) throws {
Expand All @@ -469,6 +471,7 @@ struct Action: Item, Codable, Equatable {
self.label = try c.decodeIfPresent(String.self, forKey: .label)
self.value = try c.decode(String.self, forKey: .value)
self.iconPath = try c.decodeIfPresent(String.self, forKey: .iconPath)
self.prompt = try c.decodeIfPresent(String.self, forKey: .prompt)
}

func encode(to encoder: Encoder) throws {
Expand All @@ -482,6 +485,7 @@ struct Action: Item, Codable, Equatable {
try c.encode(value, forKey: .value)
if let l = label, !l.isEmpty { try c.encode(l, forKey: .label) }
try c.encodeIfPresent(iconPath, forKey: .iconPath)
if let p = prompt, !p.isEmpty { try c.encode(p, forKey: .prompt) }
}
}

Expand Down Expand Up @@ -555,7 +559,7 @@ enum ActionOrGroup: Codable, Equatable {
}

private enum CodingKeys: String, CodingKey {
case key, type, value, actions, label, iconPath
case key, type, value, actions, label, iconPath, prompt
}

var uiid: UUID {
Expand All @@ -578,7 +582,8 @@ enum ActionOrGroup: Codable, Equatable {
self = .group(Group(key: key, label: label, iconPath: iconPath, actions: actions))
default:
let value = try container.decode(String.self, forKey: .value)
self = .action(Action(key: key, type: type, label: label, value: value, iconPath: iconPath))
let prompt = try container.decodeIfPresent(String.self, forKey: .prompt)
self = .action(Action(key: key, type: type, label: label, value: value, iconPath: iconPath, prompt: prompt))
}
}

Expand All @@ -599,6 +604,9 @@ enum ActionOrGroup: Codable, Equatable {
try container.encodeIfPresent(action.label, forKey: .label)
}
try container.encodeIfPresent(action.iconPath, forKey: .iconPath)
if action.prompt != nil && !action.prompt!.isEmpty {
try container.encodeIfPresent(action.prompt, forKey: .prompt)
}
case .group(let group):
// Always encode key in textual form for JSON
if let keyValue = group.key {
Expand Down
Loading