Skip to content
Draft
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
8 changes: 8 additions & 0 deletions Leader Key.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
objects = {

/* Begin PBXBuildFile section */
A1B2C3D42E8200000000AA02 /* AppFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8200000000AA01 /* AppFilter.swift */; };
A1B2C3D42E8200000000BB02 /* AppFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8200000000BB01 /* AppFilterTests.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 Down Expand Up @@ -68,6 +70,8 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
A1B2C3D42E8200000000AA01 /* AppFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFilter.swift; sourceTree = "<group>"; };
A1B2C3D42E8200000000BB01 /* AppFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFilterTests.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 Down Expand Up @@ -185,6 +189,7 @@
isa = PBXGroup;
children = (
42B21FBB2D67566100F4A2C7 /* Alerts.swift */,
A1B2C3D42E8200000000AA01 /* AppFilter.swift */,
427C181F2BD31C3D00955B98 /* AppDelegate.swift */,
73192AF63CAF425397D7C0D2 /* URLSchemeHandler.swift */,
42F4CDCE2D46E2B300D0DD76 /* Cheatsheet.swift */,
Expand Down Expand Up @@ -224,6 +229,7 @@
427C17FB2BD311B500955B98 /* Leader KeyTests */ = {
isa = PBXGroup;
children = (
A1B2C3D42E8200000000BB01 /* AppFilterTests.swift */,
4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */,
42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */,
EC5CEBC4C47B4C5DB2258814 /* URLSchemeTests.swift */,
Expand Down Expand Up @@ -409,6 +415,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D42E8200000000AA02 /* AppFilter.swift in Sources */,
427C182B2BD31E2E00955B98 /* Controller.swift in Sources */,
423632222D68CA6500878D92 /* MysteryBox.swift in Sources */,
605385A32D523CAD00BEDB4B /* Pulsate.swift in Sources */,
Expand Down Expand Up @@ -450,6 +457,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A1B2C3D42E8200000000BB02 /* AppFilterTests.swift in Sources */,
42454DDD2D71CBAB004E1374 /* ConfigValidatorTests.swift in Sources */,
EC5CEBC4C47B4C5DB2258813 /* URLSchemeTests.swift in Sources */,
427C17FD2BD311B500955B98 /* UserConfigTests.swift in Sources */,
Expand Down
87 changes: 87 additions & 0 deletions Leader Key/AppFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Foundation

enum AppFilter {
enum Tier: Int, Comparable {
case c = 0 // global (no `when` or both arrays empty)
case b = 1 // excludeApps non-empty, includeApps empty
case a = 2 // includeApps non-empty and contains active app

static func < (lhs: Tier, rhs: Tier) -> Bool { lhs.rawValue < rhs.rawValue }
}

/// Whether an item's `when` matches the given bundle ID.
static func matches(when: When?, bundleID: String?) -> Bool {
guard let when = when else { return true }
let include = when.includeApps ?? []
let exclude = when.excludeApps ?? []
let includeMatch = include.isEmpty || include.contains(bundleID ?? "")
let excludeMatch = exclude.isEmpty || !exclude.contains(bundleID ?? "")
return includeMatch && excludeMatch
}

/// Compute the tier of an item for a given bundle ID.
/// Returns nil if the item doesn't match (should be filtered out).
static func tier(for when: When?, bundleID: String?) -> Tier? {
guard matches(when: when, bundleID: bundleID) else { return nil }
guard let when = when else { return .c }
let include = when.includeApps ?? []
let exclude = when.excludeApps ?? []
if include.isEmpty && exclude.isEmpty { return .c }
if !include.isEmpty && include.contains(bundleID ?? "") { return .a }
if include.isEmpty && !exclude.isEmpty { return .b }
return .c
}

/// Filter and resolve actions for the frontmost app.
/// Returns items that match scope, with highest-tier winner per key.
static func resolve(actions: [ActionOrGroup], for bundleID: String?) -> [ActionOrGroup] {
// Compute tier for each item. nil means filtered out.
var tiered: [(item: ActionOrGroup, tier: Tier, index: Int)] = []
for (index, item) in actions.enumerated() {
if let t = tier(for: item.when, bundleID: bundleID) {
tiered.append((item, t, index))
}
}

// Group by normalized key
var byKey: [String: [(item: ActionOrGroup, tier: Tier, index: Int)]] = [:]
var noKey: [(item: ActionOrGroup, tier: Tier, index: Int)] = []

for entry in tiered {
let key = entry.item.item.key ?? ""
if key.isEmpty {
noKey.append(entry)
} else {
let normalized = KeyMaps.glyph(for: key) ?? key
byKey[normalized, default: []].append(entry)
}
}

// For each key, keep only the highest-tier winner(s)
var winnerIndices: Set<Int> = []

for (_, candidates) in byKey {
let maxTier = candidates.map(\.tier).max()!
let winners = candidates.filter { $0.tier == maxTier }
// Take the first winner at the highest tier (validator catches same-tier ties)
if let winner = winners.first {
winnerIndices.insert(winner.index)
}
}

// Items without keys always pass if they matched
for entry in noKey {
winnerIndices.insert(entry.index)
}

// Preserve original order
var result: [ActionOrGroup] = []
for (index, item) in actions.enumerated() {
if winnerIndices.contains(index) {
result.append(item)
}
}

return result
}
}
4 changes: 3 additions & 1 deletion Leader Key/Cheatsheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,10 @@ enum Cheatsheet {
}

var actions: [ActionOrGroup] {
(userState.currentGroup != nil)
let source =
(userState.currentGroup != nil)
? userState.currentGroup!.actions : userState.userConfig.root.actions
return AppFilter.resolve(actions: source, for: userState.frontmostBundleID)
}

var body: some SwiftUI.View {
Expand Down
122 changes: 108 additions & 14 deletions Leader Key/ConfigValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum ValidationErrorType {
case emptyKey
case nonSingleCharacterKey
case duplicateKey
case invalidWhen
}

class ConfigValidator {
Expand All @@ -33,50 +34,143 @@ class ConfigValidator {
validateKey(group.key, at: path, errors: &errors)
}

// Check for duplicate keys within this group
var keysInGroup = [String: Int]() // key: index
// Validate `when` self-consistency for the group itself
if !path.isEmpty {
validateWhen(group.when, at: path, errors: &errors)
}

// Collect items by normalized key for tier-aware duplicate detection
struct KeyEntry {
let index: Int
let when: When?
}
var keyEntries: [String: [KeyEntry]] = [:]

for (index, item) in group.actions.enumerated() {
let currentPath = path + [index]

// Get the key from the item
// Get the key and when from the item
let key: String?
let itemWhen: When?
switch item {
case .action(let action):
key = action.key
itemWhen = action.when
// Validate the key for actions
validateKey(key, at: currentPath, errors: &errors)
// Validate when self-consistency
validateWhen(itemWhen, at: currentPath, errors: &errors)
case .group(let subgroup):
key = subgroup.key
itemWhen = subgroup.when
// Recursively validate subgroups
validateGroup(subgroup, path: currentPath, errors: &errors)
// Note: We don't validate the key here because it will be validated in the recursive call
// Validate when self-consistency (done in recursive call for the group itself)
}

// Check for duplicates
// Collect entries by normalized key
if let key = key, !key.isEmpty {
// Normalize key to glyph representation for consistent comparison
let normalizedKey = KeyMaps.glyph(for: key) ?? key
keyEntries[normalizedKey, default: []].append(KeyEntry(index: index, when: itemWhen))
}
}

if let existingIndex = keysInGroup[normalizedKey] {
// Found a duplicate key
let duplicatePath = path + [existingIndex]
// Tier-aware duplicate detection
for (normalizedKey, entries) in keyEntries {
guard entries.count > 1 else { continue }

// Classify each entry by tier (using nil bundleID since we check structurally)
struct TieredEntry {
let index: Int
let when: When?
let structuralTier: AppFilter.Tier // tier based on structure, not a specific app
}

let tieredEntries: [TieredEntry] = entries.map { entry in
let tier = structuralTier(for: entry.when)
return TieredEntry(index: entry.index, when: entry.when, structuralTier: tier)
}

// Check for conflicts within each tier
let tierCEntries = tieredEntries.filter { $0.structuralTier == .c }
let tierBEntries = tieredEntries.filter { $0.structuralTier == .b }
let tierAEntries = tieredEntries.filter { $0.structuralTier == .a }

// Multiple Tier C entries for same key = error
if tierCEntries.count > 1 {
for entry in tierCEntries {
errors.append(
ValidationError(
path: duplicatePath,
path: path + [entry.index],
message: "Multiple actions for the same key '\(normalizedKey)'",
type: .duplicateKey
))
}
}

// Multiple Tier B entries for same key = error (they overlap broadly)
if tierBEntries.count > 1 {
for entry in tierBEntries {
errors.append(
ValidationError(
path: currentPath,
message: "Multiple actions for the same key '\(normalizedKey)'",
path: path + [entry.index],
message: "Multiple 'everywhere-except' actions for the same key '\(normalizedKey)'",
type: .duplicateKey
))
} else {
keysInGroup[normalizedKey] = index
}
}

// Tier A overlaps: two items with includeApps containing the same bundle ID
if tierAEntries.count > 1 {
var bundleToEntries: [String: [TieredEntry]] = [:]
for entry in tierAEntries {
for bundle in entry.when?.includeApps ?? [] {
bundleToEntries[bundle, default: []].append(entry)
}
}
var reportedIndices = Set<Int>()
for (bundle, conflicting) in bundleToEntries {
if conflicting.count > 1 {
for entry in conflicting where !reportedIndices.contains(entry.index) {
reportedIndices.insert(entry.index)
errors.append(
ValidationError(
path: path + [entry.index],
message:
"Multiple app-specific actions for '\(bundle)' on key '\(normalizedKey)'",
type: .duplicateKey
))
}
}
}
}
}
}

/// Determine the structural tier of a `when` clause (without a specific bundleID).
private static func structuralTier(for when: When?) -> AppFilter.Tier {
guard let when = when else { return .c }
let include = when.includeApps ?? []
let exclude = when.excludeApps ?? []
if include.isEmpty && exclude.isEmpty { return .c }
if !include.isEmpty { return .a }
if include.isEmpty && !exclude.isEmpty { return .b }
return .c
}

/// Validate that a `when` clause is self-consistent.
private static func validateWhen(_ when: When?, at path: [Int], errors: inout [ValidationError]) {
guard let when = when else { return }
let include = Set(when.includeApps ?? [])
let exclude = Set(when.excludeApps ?? [])
let overlap = include.intersection(exclude)
if !overlap.isEmpty {
errors.append(
ValidationError(
path: path,
message: "Bundle ID '\(overlap.first!)' appears in both includeApps and excludeApps",
type: .invalidWhen
))
}
}

Expand Down
11 changes: 8 additions & 3 deletions Leader Key/Controller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class Controller {
}

func show() {
userState.frontmostBundleID = NSWorkspace.shared.frontmostApplication?.bundleIdentifier
Events.send(.willActivate)

let screen = Defaults[.screen].getNSScreen() ?? NSScreen()
Expand Down Expand Up @@ -127,7 +128,9 @@ class Controller {
}
}

func handleKey(_ key: String, withModifiers modifiers: NSEvent.ModifierFlags? = nil, execute: Bool = true) {
func handleKey(
_ key: String, withModifiers modifiers: NSEvent.ModifierFlags? = nil, execute: Bool = true
) {
if key == "?" {
showCheatsheet()
return
Expand All @@ -137,7 +140,9 @@ class Controller {
(userState.currentGroup != nil)
? userState.currentGroup : userConfig.root

let hit = list?.actions.first { item in
let actions = AppFilter.resolve(actions: list?.actions ?? [], for: userState.frontmostBundleID)

let hit = actions.first { item in
switch item {
case .group(let group):
// Normalize both keys for comparison
Expand Down Expand Up @@ -168,7 +173,7 @@ class Controller {
}
}
}
// If execute is false, just stay visible showing the matched action
// If execute is false, just stay visible showing the matched action
case .group(let group):
if execute, let mods = modifiers, shouldRunGroupSequenceWithModifiers(mods) {
hide {
Expand Down
4 changes: 2 additions & 2 deletions Leader Key/URLSchemeHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ class URLSchemeHandler {
return .reset
case "navigate":
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems,
let keysParam = queryItems.first(where: { $0.name == "keys" })?.value
let queryItems = components.queryItems,
let keysParam = queryItems.first(where: { $0.name == "keys" })?.value
else {
return .show
}
Expand Down
Loading