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 @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
0A27E0A22EF7616100731753 /* CustomIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A27E0A12EF7616100731753 /* CustomIconRenderer.swift */; };
115AA5BF2DA521C600C17E18 /* ActionIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 115AA5BE2DA521C200C17E18 /* ActionIcon.swift */; };
115AA5C22DA546D500C17E18 /* SymbolPicker in Frameworks */ = {isa = PBXBuildFile; productRef = 115AA5C12DA546D500C17E18 /* SymbolPicker */; };
130196C62D73B3DE0093148B /* Breadcrumbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130196C52D73B3DC0093148B /* Breadcrumbs.swift */; };
Expand All @@ -15,7 +16,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 +26,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 +54,8 @@
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 */; };
EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand All @@ -68,6 +69,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
0A27E0A12EF7616100731753 /* CustomIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomIconRenderer.swift; sourceTree = "<group>"; };
115AA5BE2DA521C200C17E18 /* ActionIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionIcon.swift; sourceTree = "<group>"; };
130196C52D73B3DC0093148B /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = "<group>"; };
423632142D678F4400878D92 /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = "<group>"; };
Expand All @@ -76,7 +78,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 +89,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 +114,8 @@
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>"; };
EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -247,6 +249,7 @@
427C18392BD3268000955B98 /* Views */ = {
isa = PBXGroup;
children = (
0A27E0A12EF7616100731753 /* CustomIconRenderer.swift */,
605385A22D523CAD00BEDB4B /* Pulsate.swift */,
6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */,
427C18372BD3262100955B98 /* VisualEffectBackground.swift */,
Expand Down Expand Up @@ -414,6 +417,7 @@
605385A32D523CAD00BEDB4B /* Pulsate.swift in Sources */,
42F4CDC92D458FF700D0DD76 /* MainMenu.swift in Sources */,
42454DDB2D71CB39004E1374 /* ConfigValidator.swift in Sources */,
0A27E0A22EF7616100731753 /* CustomIconRenderer.swift in Sources */,
427C184D2BD65C5C00955B98 /* Defaults.swift in Sources */,
427C18292BD31E2E00955B98 /* MainWindow.swift in Sources */,
42FDC31A2D51687B004F5C5C /* AdvancedPane.swift in Sources */,
Expand Down
30 changes: 30 additions & 0 deletions Leader Key/Views/ActionIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ func actionIcon(item: ActionOrGroup, iconSize: NSSize, loadFavicons: Bool = true
if let iconPath = iconPath, !iconPath.isEmpty {
if iconPath.hasSuffix(".app") {
return AnyView(AppIconImage(appPath: iconPath, size: iconSize))
} else if CustomIconRenderer.isCustomImagePath(iconPath) {
// Custom image file path
return AnyView(CustomIconImage(imagePath: iconPath, size: iconSize))
} else {
return AnyView(
Image(systemName: iconPath)
Expand Down Expand Up @@ -103,6 +106,33 @@ struct AppIconImage: View {
}
}

struct CustomIconImage: View {
let imagePath: String
let size: NSSize
let defaultSystemName: String = "photo"

init(imagePath: String, size: NSSize = NSSize(width: 24, height: 24)) {
self.imagePath = imagePath
self.size = size
}

var body: some View {
let image =
if let nsImage = loadImage(path: imagePath) {
Image(nsImage: nsImage)
} else {
Image(systemName: defaultSystemName)
}
image.resizable()
.scaledToFit()
.frame(width: size.width, height: size.height)
}

private func loadImage(path: String) -> NSImage? {
CustomIconRenderer.renderCustomIcon(from: path, size: size)
}
}

struct FavIconImage: View {
let url: String
let icon: String
Expand Down
35 changes: 33 additions & 2 deletions Leader Key/Views/ConfigEditorShared.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ enum ConfigEditorUI {
anchor: NSView?,
onPickAppIcon: @escaping () -> Void,
onPickSymbol: @escaping () -> Void,
onPickCustomImage: @escaping () -> Void,
onClear: @escaping () -> Void
) {
guard let anchor else { return }
Expand All @@ -58,11 +59,17 @@ enum ConfigEditorUI {
action: #selector(MenuHandler.pickSymbol),
keyEquivalent: ""
)
menu.addItem(
withTitle: "Custom Icon…",
action: #selector(MenuHandler.pickCustomImage),
keyEquivalent: ""
)
menu.addItem(NSMenuItem.separator())
menu.addItem(withTitle: "Clear", action: #selector(MenuHandler.clearIcon), keyEquivalent: "")
let handler = MenuHandler(
onPickAppIcon: onPickAppIcon,
onPickSymbol: onPickSymbol,
onPickCustomImage: onPickCustomImage,
onClearIcon: onClear,
onDuplicate: {},
onDelete: {}
Expand All @@ -83,37 +90,55 @@ enum ConfigEditorUI {
private final class MenuHandler: NSObject {
let onPickAppIcon: (() -> Void)?
let onPickSymbol: (() -> Void)?
let onPickCustomImage: (() -> Void)?
let onClearIcon: (() -> Void)?
let onDuplicate: () -> Void
let onDelete: () -> Void

init(
onPickAppIcon: (() -> Void)? = nil,
onPickSymbol: (() -> Void)? = nil,
onPickCustomImage: (() -> Void)? = nil,
onClearIcon: (() -> Void)? = nil,
onDuplicate: @escaping () -> Void,
onDelete: @escaping () -> Void
) {
self.onPickAppIcon = onPickAppIcon
self.onPickSymbol = onPickSymbol
self.onPickCustomImage = onPickCustomImage
self.onClearIcon = onClearIcon
self.onDuplicate = onDuplicate
self.onDelete = onDelete
}

@objc func pickAppIcon() { onPickAppIcon?() }
@objc func pickSymbol() { onPickSymbol?() }
@objc func pickCustomImage() { onPickCustomImage?() }
@objc func clearIcon() { onClearIcon?() }
@objc func duplicate() { onDuplicate() }
@objc func delete() { onDelete() }
}

static func resizeAndRoundImage(_ image: NSImage, size: CGFloat) -> NSImage {
CustomIconRenderer.render(image, size: NSSize(width: size, height: size))
}
}

extension Action {
func resolvedIcon() -> NSImage? {
if let iconPath = iconPath, !iconPath.isEmpty {
if iconPath.hasSuffix(".app") { return NSWorkspace.shared.icon(forFile: iconPath) }
if let img = NSImage(systemSymbolName: iconPath, accessibilityDescription: nil) { return img }
if iconPath.hasSuffix(".app") {
return NSWorkspace.shared.icon(forFile: iconPath)
}
if let customImage = CustomIconRenderer.renderCustomIcon(
from: iconPath,
size: NSSize(width: 28, height: 28)
) {
return customImage
}
if let img = NSImage(systemSymbolName: iconPath, accessibilityDescription: nil) {
return img
}
}
switch type {
case .application:
Expand All @@ -134,6 +159,12 @@ extension Group {
func resolvedIcon() -> NSImage? {
if let iconPath = iconPath, !iconPath.isEmpty {
if iconPath.hasSuffix(".app") { return NSWorkspace.shared.icon(forFile: iconPath) }
if let customImage = CustomIconRenderer.renderCustomIcon(
from: iconPath,
size: NSSize(width: 28, height: 28)
) {
return customImage
}
if let img = NSImage(systemSymbolName: iconPath, accessibilityDescription: nil) { return img }
}
return NSImage(systemSymbolName: "folder", accessibilityDescription: nil)
Expand Down
62 changes: 62 additions & 0 deletions Leader Key/Views/ConfigOutlineEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,7 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate {
anchor: anchor,
onPickAppIcon: { self.handlePickAppIcon() },
onPickSymbol: { self.handlePickSymbol() },
onPickCustomImage: { self.handlePickCustomImage() },
onClear: { self.handleClearIcon() }
)
}
Expand Down Expand Up @@ -933,6 +934,36 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate {
)
}

@objc private func handlePickCustomImage() {
guard var a = currentAction() else { return }
let panel = NSOpenPanel()
panel.allowedContentTypes = [.png, .jpeg, .icns]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
panel.message = "Choose a custom icon image"
if panel.runModal() == .OK {
guard let path = panel.url?.path else { return }

// Validate that the file can actually be loaded as an image
guard NSImage(contentsOfFile: path) != nil else {
let alert = NSAlert()
alert.messageText = "Invalid Image File"
alert.informativeText = "The selected file could not be loaded as an image. Please choose a valid PNG, JPEG, or ICNS file."
alert.alertStyle = .warning
alert.runModal()
return
}

DispatchQueue.main.async {
a.iconPath = path
self.onChange?(.action(a))
self.updateIcon(for: a)
}
}
}

@objc private func handleClearIcon() {
guard var a = currentAction() else { return }
DispatchQueue.main.async {
Expand Down Expand Up @@ -1239,6 +1270,7 @@ private class GroupCellView: NSTableCellView, NSWindowDelegate {
anchor: anchor,
onPickAppIcon: { self.handlePickAppIcon() },
onPickSymbol: { self.handlePickSymbol() },
onPickCustomImage: { self.handlePickCustomImage() },
onClear: { self.handleClearIcon() }
)
}
Expand Down Expand Up @@ -1279,6 +1311,36 @@ private class GroupCellView: NSTableCellView, NSWindowDelegate {
)
}

@objc private func handlePickCustomImage() {
guard var g = currentGroup() else { return }
let panel = NSOpenPanel()
panel.allowedContentTypes = [.png, .jpeg, .icns]
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser
panel.message = "Choose a custom icon image"
if panel.runModal() == .OK {
guard let path = panel.url?.path else { return }

// Validate that the file can actually be loaded as an image
guard NSImage(contentsOfFile: path) != nil else {
let alert = NSAlert()
alert.messageText = "Invalid Image File"
alert.informativeText = "The selected file could not be loaded as an image. Please choose a valid PNG, JPEG, or ICNS file."
alert.alertStyle = .warning
alert.runModal()
return
}

DispatchQueue.main.async {
g.iconPath = path
self.onChange?(.group(g))
self.updateIcon(for: g)
}
}
}

@objc private func handleClearIcon() {
guard var g = currentGroup() else { return }
DispatchQueue.main.async {
Expand Down
63 changes: 63 additions & 0 deletions Leader Key/Views/CustomIconRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import AppKit

enum CustomIconRenderer {
static let cornerRadiusFactor: CGFloat = 0.2237
static let contentScale: CGFloat = 0.84

static func isCustomImagePath(_ path: String) -> Bool {
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
return exists && !isDirectory.boolValue
}

static func renderCustomIcon(from path: String, size: NSSize) -> NSImage? {
guard isCustomImagePath(path),
let image = NSImage(contentsOfFile: path) else {
return nil
}
return render(image, size: size)
}

static func render(_ image: NSImage, size: NSSize) -> NSImage {
let targetSize = NSSize(width: size.width, height: size.height)
let outputImage = NSImage(size: targetSize)

outputImage.lockFocus()
defer { outputImage.unlockFocus() }

// Scale down the icon content to match app bundle icon sizing
let scaledWidth = size.width * contentScale
let scaledHeight = size.height * contentScale
let inset = (size.width - scaledWidth) / 2
let destRect = NSRect(x: inset, y: inset, width: scaledWidth, height: scaledHeight)

let cornerRadius = min(scaledWidth, scaledHeight) * cornerRadiusFactor
let clipPath = NSBezierPath(roundedRect: destRect, xRadius: cornerRadius, yRadius: cornerRadius)
clipPath.addClip()

let drawRect = aspectFillRect(for: image.size, in: destRect)
image.draw(
in: drawRect,
from: NSRect(origin: .zero, size: image.size),
operation: .sourceOver,
fraction: 1.0
)

return outputImage
}

private static func aspectFillRect(for sourceSize: NSSize, in destRect: NSRect) -> NSRect {
guard sourceSize.width > 0, sourceSize.height > 0 else { return destRect }

let widthScale = destRect.width / sourceSize.width
let heightScale = destRect.height / sourceSize.height
let scale = max(widthScale, heightScale)
let scaledSize = NSSize(width: sourceSize.width * scale, height: sourceSize.height * scale)
let origin = NSPoint(
x: destRect.midX - scaledSize.width / 2,
y: destRect.midY - scaledSize.height / 2
)

return NSRect(origin: origin, size: scaledSize)
}
}