diff --git a/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift b/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift index 5ad6c48a..9bd377a8 100644 --- a/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift +++ b/Sources/KeyPathAppKit/Services/Packs/PackRegistry.swift @@ -20,7 +20,8 @@ public enum PackRegistry { autoShiftSymbols, numpadLayer, symbolLayer, - funLayer + funLayer, + launcher ] /// Look up a pack by id. Returns nil if unknown. @@ -387,4 +388,30 @@ public enum PackRegistry { bindings: [], associatedCollectionID: RuleCollectionIdentifier.funLayer ) + + // MARK: - Pack 13: Quick Launcher + + /// Collection-backed pack over `launcher`. Hold the Hyper key to enter + /// the launcher layer, then press a single key to launch an app or + /// open a URL. Pack Detail embeds the same `LauncherCollectionView` + /// the Rules tab uses so you can pick keys, drop apps, and switch + /// between Hold-Hyper and Leader→L activation right from the pack. + /// + /// Unlike the nav-layer packs (Numpad, Symbol, Fun, Mission Control, + /// etc.), Launcher activates directly from the base layer via Hyper — + /// so it works standalone without a Leader pack on. + public static let launcher = Pack( + id: "com.keypath.pack.quick-launcher", + version: "1.0.0", + name: "Quick Launcher", + tagline: "Hold Hyper, press a key to launch an app or website", + shortDescription: + "Map any key to launch an app (Slack, Cursor, Figma) or open a URL (gmail.com, calendar.google.com). Tap Hyper + the key. Add and edit mappings inline — drag an app onto a key, or pick from your browser history.", + longDescription: "", + category: "Productivity", + iconSymbol: "arrow.up.forward.app", + quickSettings: [], + bindings: [], + associatedCollectionID: RuleCollectionIdentifier.launcher + ) } diff --git a/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift b/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift index 6b00074d..b8855d27 100644 --- a/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift +++ b/Sources/KeyPathAppKit/UI/Gallery/PackDetailView.swift @@ -51,6 +51,9 @@ struct PackDetailView: View { /// Currently-selected layer-preset id (for the Symbol/Fun packs). @State private var selectedLayerPresetId: String? + /// Local state for the embedded Quick Launcher editor. + @State private var launcherConfig: LauncherGridConfig = .defaultConfig + /// Help sheet presentation (Home Row Mods only — mirrors the Rules tab's /// `?` button that opens the HRM markdown help). @State private var showingHomeRowModsHelp = false @@ -488,6 +491,18 @@ struct PackDetailView: View { ) .id(selectedLayerPresetId ?? "") } + } else if let launcherCollection = associatedLauncherCollection { + // Quick Launcher — keyboard visualization + drawer of mappings, + // activation-mode picker. Same view Rules tab uses; edits flow + // through the same `updateLauncherConfig` VM hook. + embeddedEditor(horizontalPadding: 12) { + LauncherCollectionView( + config: $launcherConfig, + onConfigChanged: { newConfig in + Task { await applyLauncherEdit(newConfig, collectionID: launcherCollection.id) } + } + ) + } } else if let collection = liveAssociatedCollection, collection.id == RuleCollectionIdentifier.windowSnapping { // Window Snapping has a custom visual editor in Rules — convention @@ -587,6 +602,15 @@ struct PackDetailView: View { return collection } + /// Collection backing a launcher pack (Quick Launcher). Matches the + /// `.launcherGrid` configuration case. + private var associatedLauncherCollection: RuleCollection? { + guard let collection = liveAssociatedCollection, + case .launcherGrid = collection.configuration + else { return nil } + return collection + } + /// True if this pack's bindings line up with the Home Row Mods /// collection's input set — in which case we render that collection's /// interactive keyboard + modifier controls instead of the generic @@ -777,6 +801,9 @@ struct PackDetailView: View { if let livePreset = liveLayerPresetId() { selectedLayerPresetId = livePreset } + if let liveLauncher = liveLauncherConfig() { + launcherConfig = liveLauncher + } } } @@ -794,6 +821,13 @@ struct PackDetailView: View { return cfg.selectedPresetId } + private func liveLauncherConfig() -> LauncherGridConfig? { + guard let collection = associatedLauncherCollection, + case let .launcherGrid(cfg) = collection.configuration + else { return nil } + return cfg + } + private func liveSingleKeySelection() async -> String? { guard let collectionID = pack.associatedCollectionID else { return nil } let collections = await kanataManager.underlyingManager @@ -856,6 +890,16 @@ struct PackDetailView: View { await kanataManager.updateCollectionLayerPreset(collectionID, presetId: presetId) } + /// Mirror of `applyHomeRowEdit` for the Quick Launcher pack. Persists + /// activation-mode + key mappings via the same VM hook Rules uses. + private func applyLauncherEdit(_ newConfig: LauncherGridConfig, collectionID: UUID) async { + launcherConfig = newConfig + if !isInstalled { + await install(skipFinalReload: true) + } + await kanataManager.updateLauncherConfig(collectionID, config: newConfig) + } + /// Supplies the same layer list Rules uses when rendering the Home Row /// Mods editor, so hold-to-layer bindings resolve correctly inside /// Pack Detail. diff --git a/Tests/KeyPathTests/PackRegistryTests.swift b/Tests/KeyPathTests/PackRegistryTests.swift index 3935a0eb..d3966b0d 100644 --- a/Tests/KeyPathTests/PackRegistryTests.swift +++ b/Tests/KeyPathTests/PackRegistryTests.swift @@ -25,6 +25,7 @@ final class PackRegistryTests: XCTestCase { XCTAssertTrue(ids.contains("com.keypath.pack.numpad-layer")) XCTAssertTrue(ids.contains("com.keypath.pack.symbol-layer")) XCTAssertTrue(ids.contains("com.keypath.pack.fun-layer")) + XCTAssertTrue(ids.contains("com.keypath.pack.quick-launcher")) } func testCollectionBackedPacksPointAtRealCollections() {