diff --git a/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift b/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift index f689e90a5..a0de3fd86 100644 --- a/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift +++ b/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift @@ -19,7 +19,8 @@ public enum PackRegistry { missionControl, autoShiftSymbols, numpadLayer, - symbolLayer + symbolLayer, + funLayer ] /// Look up a pack by id. Returns nil if unknown. @@ -260,7 +261,7 @@ public enum PackRegistry { name: "Mission Control", tagline: "Leader + single key for Exposé, Desktops, Notifications", shortDescription: - "Hold Space, then: M = Mission Control, E = App Exposé, B = Show Desktop, C = Notification Center, [ / ] for previous / next Desktop. One-hand shortcuts, no reach for F3.", + "Hold Space, then: M = Mission Control, Q = App Exposé, T = Show Desktop, C = Notification Center, , / . for previous / next Desktop. Requires a Leader pack on (Vim Navigation, KindaVim, or Neovim Terminal) for Space to activate the nav layer.", longDescription: "", category: "Productivity", iconSymbol: "rectangle.3.group", @@ -362,4 +363,28 @@ public enum PackRegistry { ], associatedCollectionID: RuleCollectionIdentifier.symbolLayer ) + + // MARK: - Pack 12: Function / Media Layer + + /// Collection-backed pack over `funLayer`. Two-step activation: + /// hold Space for nav, hold `f` to enter the fun layer. Right hand + /// becomes a 3x4 F-key numpad (u/i/o = F7/F8/F9, j/k/l = F4/F5/F6, + /// m/,/. = F1/F2/F3, n = F10, / = F11, ; = F12). Left hand becomes + /// media/system controls (f = Play/Pause, d = Prev, s = Next, a = + /// Mute, g = Vol Up, r = Vol Down, v = Brightness Up, c = Brightness + /// Down). + public static let funLayer = Pack( + id: "com.keypath.pack.fun-layer", + version: "1.0.0", + name: "Function", + tagline: "Right hand becomes F-keys, left hand is media/brightness", + shortDescription: + "Hold Space, press `f`, then: right hand (u/i/o + j/k/l + m/,/.) is an F-key grid; left hand is media — play/pause, prev/next, mute, volume, brightness. Everything reachable from home position.", + longDescription: "", + category: "Layers", + iconSymbol: "f.cursive", + quickSettings: [], + bindings: [], + associatedCollectionID: RuleCollectionIdentifier.funLayer + ) } diff --git a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift index c8868c692..bc5add8ee 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollections/RuleCollectionCatalog.swift @@ -333,29 +333,27 @@ struct RuleCollectionCatalog { // and consistent with the rest of the Gallery (Vim Nav, Numpad, // Window Snapping all share the Leader → key pattern). // - // Keys chosen to avoid colliding with Vim Navigation's nav-layer - // bindings (h/j/k/l, y/p, g, u, d, n, o, a, r, /, 4, 0, x, .). + // Keys chosen to avoid colliding with BOTH Vim Navigation AND + // KindaVim nav-layer bindings (both pack types share Space → + // nav; a user can have either enabled). Safe unclaimed keys: + // `q t c v m , .` — picked for mnemonic fit where possible. mappings: [ KeyMapping(input: "m", output: "C-up", description: "Mission Control"), - KeyMapping(input: "e", output: "C-down", description: "App Exposé"), - KeyMapping(input: "[", output: "C-left", description: "Previous Desktop"), - KeyMapping(input: "]", output: "C-right", description: "Next Desktop"), - KeyMapping(input: "b", output: "f11", description: "Show Desktop"), - KeyMapping(input: "c", output: "C-S-n", description: "Notification Center") + KeyMapping(input: "q", output: "C-down", description: "App Exposé"), + KeyMapping(input: "t", output: "f11", description: "Show Desktop"), + KeyMapping(input: "c", output: "C-S-n", description: "Notification Center"), + KeyMapping(input: ",", output: "C-left", description: "Previous Desktop"), + KeyMapping(input: ".", output: "C-right", description: "Next Desktop") ], isEnabled: false, isSystemDefault: false, icon: "rectangle.3.group", tags: ["mission control", "spaces", "desktop"], + // Additive nav-layer pack — piggybacks on whichever nav + // provider the user has enabled (Vim Navigation, KindaVim, or + // Neovim Terminal). Doesn't declare its own Space activator + // because that would collide with the nav provider's. targetLayer: .navigation, - // Piggybacks on the nav layer — the user needs a collection that - // activates nav (Vim Navigation or Leader Key). Providing our - // own Space activator keeps the pack standalone-usable; the - // generator's dedupe coalesces duplicate activators. - momentaryActivator: MomentaryActivator( - input: "space", - targetLayer: .navigation - ), activationHint: "Leader → single key", configuration: .table ) @@ -981,7 +979,12 @@ struct RuleCollectionCatalog { icon: "f.cursive", tags: ["function", "f-keys", "media", "brightness"], targetLayer: .custom("fun"), - activationHint: "Hold home row key for function layer", + momentaryActivator: MomentaryActivator( + input: "f", + targetLayer: .custom("fun"), + sourceLayer: .navigation // Two-step: Leader → f → fun layer + ), + activationHint: "Leader → f → function keys", configuration: .table ) } diff --git a/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift b/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift index 0ce110150..5051c4203 100644 --- a/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift +++ b/Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift @@ -100,7 +100,12 @@ final class PackRuntimeBehaviorTests: XCTestCase { /// harder to press than the F3 it claimed to improve on. func testMissionControlLeaderMEmitsCtrlUp() throws { let events = try simulate( - collectionIDs: [RuleCollectionIdentifier.missionControl], + collectionIDs: [ + RuleCollectionIdentifier.missionControl, + // MC is an additive nav-layer pack — it needs a nav + // provider (Vim Nav) on to supply the Space activator. + RuleCollectionIdentifier.vimNavigation + ], // Hold Space (→ nav layer), tap `m`, release. script: "↓spc 🕐300 ↓m 🕐30 ↑m 🕐30 ↑spc 🕐30" ) @@ -165,6 +170,22 @@ final class PackRuntimeBehaviorTests: XCTestCase { "Holding space + s should put us in the 'sym' layer; got: \(result.finalLayer ?? "nil")") } + /// Fun Layer is nested under nav (Leader → `f`). Once in fun, the + /// right-hand numpad grid emits F-keys — `u` should emit `f7`. + func testFunLayerUEmitsF7() throws { + let events = try simulate( + collectionIDs: [ + RuleCollectionIdentifier.funLayer, + RuleCollectionIdentifier.vimNavigation + ], + // Space held (→ nav), hold `f` (→ fun), tap `u`. + script: "↓spc 🕐300 ↓f 🕐100 ↓u 🕐30 ↑u 🕐30 ↑f 🕐20 ↑spc 🕐20" + ) + let output = outputKeys(events) + XCTAssertTrue(output.contains("f7"), + "u inside fun layer should emit f7; got: \(output)") + } + /// Vim Navigation's headline: hold Space → nav layer; inside the layer, /// h/j/k/l emit arrow keys. Without the Space-hold activation the letter /// should come through as-is. This checks both paths in one script. diff --git a/Tests/KeyPathTests/Config/RuleCollectionKanataValidationTests.swift b/Tests/KeyPathTests/Config/RuleCollectionKanataValidationTests.swift index 2f46d3935..02822fc6f 100644 --- a/Tests/KeyPathTests/Config/RuleCollectionKanataValidationTests.swift +++ b/Tests/KeyPathTests/Config/RuleCollectionKanataValidationTests.swift @@ -80,6 +80,20 @@ final class RuleCollectionKanataValidationTests: XCTestCase { ]) } + /// Enable every catalog collection at once and run kanata --check on + /// the resulting config. This catches emergent conflicts between + /// collections (activator collisions, defcfg option clashes, chord/ + /// tap-hold interactions, etc.) that per-collection tests miss. + func testEveryCatalogCollectionEnabledAtOnce() throws { + guard let kanata = kanataURL else { + throw XCTSkip("kanata binary not available (set KEYPATH_KANATA_PATH or build External/kanata)") + } + let catalog = RuleCollectionCatalog().defaultCollections() + let allEnabled = catalog.map { $0.withIsEnabled(true) } + let config = KanataConfiguration.generateFromCollections(allEnabled) + try assertKanataAccepts(config, kanata: kanata, label: "every collection enabled") + } + // MARK: - Helpers private func validateCombo(named label: String, ids: [UUID]) throws { diff --git a/Tests/KeyPathTests/PackRegistryTests.swift b/Tests/KeyPathTests/PackRegistryTests.swift index f40694ff5..3935a0eb7 100644 --- a/Tests/KeyPathTests/PackRegistryTests.swift +++ b/Tests/KeyPathTests/PackRegistryTests.swift @@ -24,6 +24,7 @@ final class PackRegistryTests: XCTestCase { XCTAssertTrue(ids.contains("com.keypath.pack.auto-shift-symbols")) XCTAssertTrue(ids.contains("com.keypath.pack.numpad-layer")) XCTAssertTrue(ids.contains("com.keypath.pack.symbol-layer")) + XCTAssertTrue(ids.contains("com.keypath.pack.fun-layer")) } func testCollectionBackedPacksPointAtRealCollections() { diff --git a/Tests/KeyPathTests/RuleCollections/RuleCollectionCollisionTests.swift b/Tests/KeyPathTests/RuleCollections/RuleCollectionCollisionTests.swift new file mode 100644 index 000000000..78652d4a9 --- /dev/null +++ b/Tests/KeyPathTests/RuleCollections/RuleCollectionCollisionTests.swift @@ -0,0 +1,92 @@ +@testable import KeyPathAppKit +import XCTest + +/// Static structural asserts on the default collection catalog. +/// +/// Today's config generator silently resolves two kinds of collisions by +/// keeping the first-registered definition: +/// * Two collections declaring the same `momentaryActivator(input, sourceLayer)`. +/// * Two collections mapping the same input key in the same `targetLayer`. +/// +/// Either can mask a real product bug ("I added a pack and it doesn't work, +/// another pack just silently took the key"). These tests surface the +/// collision at build time instead of letting it fail silently at runtime. +/// +/// ~5ms total, no kanata binary needed. +final class RuleCollectionCollisionTests: XCTestCase { + private var collections: [RuleCollection] { + RuleCollectionCatalog().defaultCollections() + } + + // MARK: - Activator uniqueness + + func testNoTwoCollectionsShareAnActivator() { + // Intentionally-overlapping activators. The nav-providing packs + // (Vim Navigation, KindaVim, Neovim Terminal) all use Space as + // Leader — a user is meant to pick one, so the shared activator + // is by design rather than a bug. + let navProviderIDs: Set = [ + RuleCollectionIdentifier.vimNavigation, + RuleCollectionIdentifier.kindaVim, + RuleCollectionIdentifier.neovimTerminal + ] + + // Key: ":". Value: first-registering collection name. + var claimed: [String: String] = [:] + var collisions: [(key: String, existing: String, incoming: String)] = [] + + for collection in collections { + guard let activator = collection.momentaryActivator else { continue } + let key = "\(activator.sourceLayer.kanataName):\(activator.input.lowercased())" + if let existing = claimed[key] { + // Allow overlap among known mutually-exclusive nav providers. + if navProviderIDs.contains(collection.id), + let existingCollection = collections.first(where: { $0.name == existing }), + navProviderIDs.contains(existingCollection.id) + { + continue + } + collisions.append((key, existing, collection.name)) + } else { + claimed[key] = collection.name + } + } + + XCTAssertTrue(collisions.isEmpty, + "Duplicate momentaryActivators detected:\n" + + collisions.map { " \($0.key): \($0.existing) ↔ \($0.incoming)" }.joined(separator: "\n")) + } + + // MARK: - Mapping uniqueness per target layer + + func testNoTwoCollectionsMapTheSameInputInTheSameLayer() { + // Key: ":". Value: (collection name, output). + var claimed: [String: (name: String, output: String)] = [:] + var collisions: [String] = [] + + for collection in collections { + let layerKey = collection.targetLayer.kanataName + for mapping in collection.mappings { + // Space-separated chord inputs aren't "same input" — they + // land in defchordsv2 separately. Skip them here. + if mapping.input.contains(" ") { continue } + let key = "\(layerKey):\(mapping.input.lowercased())" + if let existing = claimed[key] { + // Same output across two collections is fine (both agree). + if existing.output != mapping.output { + collisions.append( + "\(key): \(existing.name)(\(existing.output)) ↔ " + + "\(collection.name)(\(mapping.output))" + ) + } + } else { + claimed[key] = (collection.name, mapping.output) + } + } + } + + XCTAssertTrue(collisions.isEmpty, + "Input-key collisions in the same target layer:\n" + + collisions.joined(separator: "\n")) + } +} diff --git a/docs/gallery/pack-migration-plan.md b/docs/gallery/pack-migration-plan.md index e8a8a1be0..4c155279d 100644 --- a/docs/gallery/pack-migration-plan.md +++ b/docs/gallery/pack-migration-plan.md @@ -48,9 +48,10 @@ The collection's Rules-tab editor exists but isn't generalized for Pack Detail y | Collection | Config type | New editor needed | |------------|-------------|-------------------| -| Numpad / Symbol / Fun layers | layerPresetPicker | Layer preset picker with key-map preview | | Launcher (Quick Launcher) | launcherGrid | Per-key app/target grid | +> **Shipped:** Numpad (nested layer via `;`), Symbol (nested layer via `s`), and Fun (nested layer via `f`) all use `.table` / `.layerPresetPicker` configs and piggyback on `MappingTableContent` or `LayerPresetPickerContent` — no new embedded editor needed. + > **Removed:** Home Row Layer Toggles was on this list initially but is > legacy — it's been unified into Home Row Mods (via `holdMode: .layers`). > `RuleCollectionsManagerTests.testCatalogDoesNotExposeSeparateHomeRowLayerTogglesRule`