diff --git a/Leader Key.xcodeproj/project.pbxproj b/Leader Key.xcodeproj/project.pbxproj index 141cebf6..8ed03ae3 100644 --- a/Leader Key.xcodeproj/project.pbxproj +++ b/Leader Key.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 */; }; @@ -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 */; }; @@ -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 */ @@ -68,6 +69,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0A27E0A12EF7616100731753 /* CustomIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomIconRenderer.swift; sourceTree = ""; }; 115AA5BE2DA521C200C17E18 /* ActionIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionIcon.swift; sourceTree = ""; }; 130196C52D73B3DC0093148B /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = ""; }; 423632142D678F4400878D92 /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = ""; }; @@ -76,7 +78,6 @@ 423632272D6A806700878D92 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 42454DDA2D71CB39004E1374 /* ConfigValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidator.swift; sourceTree = ""; }; 42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigValidatorTests.swift; sourceTree = ""; }; - EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeTests.swift; sourceTree = ""; }; 4254953F2D75EFAD0020300E /* ForTheHorde.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForTheHorde.swift; sourceTree = ""; }; 426E625A2D2E6A98009FD2F2 /* CommandRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandRunner.swift; sourceTree = ""; }; 4279AFEA2C6A08B100952A83 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = "Leader Key/Support/Info.plist"; sourceTree = SOURCE_ROOT; }; @@ -88,7 +89,6 @@ 427C17FC2BD311B500955B98 /* UserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConfigTests.swift; sourceTree = ""; }; 427C181B2BD314B500955B98 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 427C181F2BD31C3D00955B98 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeHandler.swift; sourceTree = ""; }; 427C18242BD31E2E00955B98 /* GeneralPane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneralPane.swift; sourceTree = ""; }; 427C18252BD31E2E00955B98 /* MainWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; 427C18262BD31E2E00955B98 /* StatusItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = ""; }; @@ -114,6 +114,8 @@ 6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigOutlineEditorView.swift; sourceTree = ""; }; 6D9B9C032DBA000000000002 /* KeyCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCapture.swift; sourceTree = ""; }; 6D9B9C052DBA000000000003 /* ConfigEditorShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigEditorShared.swift; sourceTree = ""; }; + 73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeHandler.swift; sourceTree = ""; }; + EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemeTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -247,6 +249,7 @@ 427C18392BD3268000955B98 /* Views */ = { isa = PBXGroup; children = ( + 0A27E0A12EF7616100731753 /* CustomIconRenderer.swift */, 605385A22D523CAD00BEDB4B /* Pulsate.swift */, 6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */, 427C18372BD3262100955B98 /* VisualEffectBackground.swift */, @@ -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 */, diff --git a/Leader Key/Views/ActionIcon.swift b/Leader Key/Views/ActionIcon.swift index 74d37e9a..898e9502 100644 --- a/Leader Key/Views/ActionIcon.swift +++ b/Leader Key/Views/ActionIcon.swift @@ -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) @@ -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 diff --git a/Leader Key/Views/ConfigEditorShared.swift b/Leader Key/Views/ConfigEditorShared.swift index 2da726fc..7b01143c 100644 --- a/Leader Key/Views/ConfigEditorShared.swift +++ b/Leader Key/Views/ConfigEditorShared.swift @@ -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 } @@ -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: {} @@ -83,6 +90,7 @@ 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 @@ -90,12 +98,14 @@ enum ConfigEditorUI { 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 @@ -103,17 +113,32 @@ enum ConfigEditorUI { @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: @@ -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) diff --git a/Leader Key/Views/ConfigOutlineEditorView.swift b/Leader Key/Views/ConfigOutlineEditorView.swift index 5838cc5b..e946cd22 100644 --- a/Leader Key/Views/ConfigOutlineEditorView.swift +++ b/Leader Key/Views/ConfigOutlineEditorView.swift @@ -893,6 +893,7 @@ private class ActionCellView: NSTableCellView, NSWindowDelegate { anchor: anchor, onPickAppIcon: { self.handlePickAppIcon() }, onPickSymbol: { self.handlePickSymbol() }, + onPickCustomImage: { self.handlePickCustomImage() }, onClear: { self.handleClearIcon() } ) } @@ -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 { @@ -1239,6 +1270,7 @@ private class GroupCellView: NSTableCellView, NSWindowDelegate { anchor: anchor, onPickAppIcon: { self.handlePickAppIcon() }, onPickSymbol: { self.handlePickSymbol() }, + onPickCustomImage: { self.handlePickCustomImage() }, onClear: { self.handleClearIcon() } ) } @@ -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 { diff --git a/Leader Key/Views/CustomIconRenderer.swift b/Leader Key/Views/CustomIconRenderer.swift new file mode 100644 index 00000000..d104bb98 --- /dev/null +++ b/Leader Key/Views/CustomIconRenderer.swift @@ -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) + } +}