Skip to content
Merged
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
29 changes: 27 additions & 2 deletions Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ public enum PackRegistry {
missionControl,
autoShiftSymbols,
numpadLayer,
symbolLayer
symbolLayer,
funLayer
]

/// Look up a pack by id. Returns nil if unknown.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
}
Expand Down
23 changes: 22 additions & 1 deletion Tests/KeyPathTests/Config/PackRuntimeBehaviorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Tests/KeyPathTests/PackRegistryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UUID> = [
RuleCollectionIdentifier.vimNavigation,
RuleCollectionIdentifier.kindaVim,
RuleCollectionIdentifier.neovimTerminal
]

// Key: "<sourceLayer>:<input>". 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: "<targetLayer>:<input>". 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"))
}
}
3 changes: 2 additions & 1 deletion docs/gallery/pack-migration-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading