{
  "format" : 1,
  "manifest" : {
    "accent" : "indigo",
    "capabilities" : [
      {
        "apps" : {

        }
      },
      {
        "keyLayer" : {

        }
      },
      {
        "openURLs" : {
          "schemes" : [
            "x-apple.systempreferences"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/AppSwitcher",
    "id" : "io.navbytes.stackbar.plugins.app-switcher",
    "minimumStackbar" : "0.17.0",
    "name" : "App Switcher",
    "summary" : "Hold Right ⌘ and press an app's first letter to switch to it. Press again to cycle or hide.",
    "surfaces" : [
      "menuBar",
      "panel",
      "settings"
    ],
    "symbol" : "command",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "apps" : {

        }
      },
      {
        "every" : {
          "seconds" : 20
        }
      }
    ],
    "version" : "1.1.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "KoVN1blhELryfJ4AlY5GKX8M1klEUCoohiSeLvG/dzwdoSsySu9H9G8BN21JCz4GXe41hId5Y14sAlVjuh57BA==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Switch apps without ⌘Tab: hold Right ⌘ and press an app's first letter.\n/// Pressing the letter again moves to the next app with that letter, or hides\n/// the app when it is the only one. Pin a letter to an app with Right ⌘ ⌥ and\n/// the letter, and that letter opens the app even when it isn't running.\n/// Holding Right ⌘ on its own shows the letter map. The host listens to the\n/// keyboard and switches inside the key press, so it stays instant. Needs\n/// `.keyLayer` and `.apps`, and Accessibility access for Stackbar.\n@main\nfinal class AppSwitcher: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.app-switcher\",\n        name: \"App Switcher\",\n        version: \"1.1.0\",\n        summary: \"Hold Right ⌘ and press an app's first letter to switch to it. Press again to cycle or hide.\",\n        surfaces: [.menuBar, .panel, .settings],\n        triggers: [.launch, .apps, .every(.seconds(20))],\n        capabilities: [.apps, .keyLayer, .openURLs(schemes: [\"x-apple.systempreferences\"])],\n        minimumStackbar: \"0.17.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Productivity/AppSwitcher\",\n        accent: .indigo,\n        symbol: \"command\"\n    )\n\n    // MARK: Previews\n\n    static let previews: [PreviewScene<AppSwitcher>] = [\n        PreviewScene(\"hero\", \"Hold Right ⌘ and press a letter\") { plugin in\n            plugin.apps = sampleApps\n            plugin.pins = [\"m\": [\"com.apple.mail\"], \"t\": [\"com.apple.Terminal\"]]\n            plugin.pinNames = [\"com.apple.mail\": \"Mail\", \"com.apple.Terminal\": \"Terminal\"]\n        },\n    ]\n\n    static let sampleApps = [\n        RunningApp(id: \"com.apple.finder\", name: \"Finder\"),\n        RunningApp(id: \"com.apple.Safari\", name: \"Safari\", isActive: true),\n        RunningApp(id: \"com.tinyspeck.slackmacgap\", name: \"Slack\"),\n        RunningApp(id: \"com.spotify.client\", name: \"Spotify\"),\n        RunningApp(id: \"com.apple.dt.Xcode\", name: \"Xcode\"),\n        RunningApp(id: \"com.apple.Notes\", name: \"Notes\"),\n        RunningApp(id: \"com.figma.Desktop\", name: \"Figma\"),\n        RunningApp(id: \"com.apple.iCal\", name: \"Calendar\"),\n        RunningApp(id: \"com.apple.Terminal\", name: \"Terminal\"),\n    ]\n\n    let theme = Theme.indigo\n\n    /// Pinned letters: key → bundle ids (one app per pinned key).\n    @Stored(\"pins\") var pins: [String: [String]] = [:]\n    /// Names of pinned apps, for when they aren't running.\n    @Stored(\"pinNames\") var pinNames: [String: String] = [:]\n    @Stored(\"trigger\") var trigger = KeyLayerTrigger.rightCommand.rawValue\n    @Stored(\"secondPress\") var secondPress = SecondPress.hide.rawValue\n    @Stored(\"autoLetters\") var autoLetters = true\n    @Stored(\"openPinned\") var openPinned = true\n    @Stored(\"overlay\") var overlay = true\n\n    var apps: [RunningApp] = []\n    var status: KeyLayerStatus?\n    var problem: String?\n    var notice: String?\n    /// The layer last sent, so an unchanged map isn't sent again.\n    var sent: KeyLayer?\n\n    func refresh() async throws {\n        do {\n            apps = try await Stackbar.runningApps()\n            problem = nil\n        } catch {\n            problem = \"\\(error)\"\n        }\n        await apply()\n    }\n\n    // MARK: - The map\n\n    var triggerKey: KeyLayerTrigger { KeyLayerTrigger(rawValue: trigger) ?? .rightCommand }\n    var secondPressMode: SecondPress { SecondPress(rawValue: secondPress) ?? .hide }\n    var assignKey: String { triggerKey == .rightOption ? \"Right ⌘\" : \"Right ⌥\" }\n\n    var layer: KeyLayer {\n        KeyLayer(trigger: triggerKey, bindings: Self.bindings(running: apps, pins: pins, autoLetters: autoLetters, openPinned: openPinned), secondPress: secondPressMode, assignCommand: \"assign\", overlay: overlay)\n    }\n\n    /// The letter an app gets on its own: the first letter or digit of its\n    /// name, accents folded (\"Écrit\" → e). Nil for names with none.\n    static func letter(for name: String) -> String? {\n        let folded = name.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil)\n        return folded.unicodeScalars.lazy.map(String.init).first(where: KeyBinding.isValidKey)\n    }\n\n    /// Pinned apps first on their key (and opened when closed if\n    /// `openPinned`), then running apps on their own letter in launch order.\n    /// A pinned app only lives on its pinned key.\n    static func bindings(running: [RunningApp], pins: [String: [String]], autoLetters: Bool, openPinned: Bool) -> [KeyBinding] {\n        let pinned = Set(pins.values.flatMap { $0 })\n        var byKey: [String: [String]] = pins.filter { KeyBinding.isValidKey($0.key) && !$0.value.isEmpty }\n        if autoLetters {\n            for app in running where !pinned.contains(app.id) {\n                guard let key = letter(for: app.name) else { continue }\n                if !(byKey[key] ?? []).contains(app.id) { byKey[key, default: []].append(app.id) }\n            }\n        }\n        return byKey.keys.sorted().map { key in\n            let isPinned = pins[key]?.isEmpty == false\n            return KeyBinding(key: key, apps: Array(byKey[key]!.prefix(KeyLayer.maxAppsPerKey)), launch: openPinned && isPinned)\n        }\n    }\n\n    func apply() async {\n        let next = layer\n        do {\n            status = next == sent ? try await Stackbar.keyLayerStatus() : try await Stackbar.setKeyLayer(next)\n            sent = next\n        } catch {\n            problem = \"\\(error)\"\n        }\n    }\n\n    func name(of id: String) -> String {\n        apps.first { $0.id == id }?.name ?? pinNames[id] ?? id\n    }\n\n    func isRunning(_ id: String) -> Bool { apps.contains { $0.id == id } }\n\n    // MARK: - Actions\n\n    /// Pins `key` to the app (from the chord: the app in front). Pinning the\n    /// same app to the same key again unpins it.\n    func assign(key rawKey: String, app id: String) async -> CommandResult {\n        let key = rawKey.lowercased()\n        guard KeyBinding.isValidKey(key) else { return .failure(\"Pin a letter or digit\") }\n        guard !id.isEmpty else { return .failure(\"No app is in front\") }\n        if let fresh = try? await Stackbar.runningApps() { apps = fresh }\n        let name = name(of: id)\n        if pins[key] == [id] {\n            pins[key] = nil\n            notice = \"\\(key.uppercased()) is no longer pinned to \\(name)\"\n        } else {\n            for other in pins.keys where pins[other]?.contains(id) == true { pins[other] = nil }\n            pins[key] = [id]\n            pinNames[id] = name\n            notice = \"\\(triggerKey.title) \\(key.uppercased()) now opens \\(name)\"\n        }\n        await apply()\n        return .message(notice)\n    }\n\n    func unpin(_ key: String) async {\n        guard let id = pins[key]?.first else { return }\n        pins[key] = nil\n        notice = \"\\(key.uppercased()) is no longer pinned to \\(name(of: id))\"\n        await apply()\n    }\n\n    func set(_ change: () -> Void) async {\n        change()\n        await apply()\n    }\n\n    // MARK: - Surfaces\n\n    var menuBar: ViewContent {\n        Label(\"\", symbol: status?.listening == false ? \"command.circle\" : \"command\").role(.secondary)\n    }\n\n    @ViewBuilder var statusBanner: ViewContent {\n        if let problem {\n            Banner(problem, theme: .warning)\n        } else if let status {\n            if status.needsAccessibility {\n                Banner(\"Stackbar needs Accessibility access to hear \\(triggerKey.title).\", theme: .warning) {\n                    ActionButton(\"Open Settings\", theme: theme, key: \"accessibility\") {\n                        Stackbar.open(\"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility\")\n                    }\n                }\n            } else if let conflict = status.conflict {\n                Banner(\"\\(conflict) already uses \\(triggerKey.title). Pick another key in Settings.\", theme: .warning)\n            } else if let app = status.secureInputApp {\n                Banner(\"Paused: \\(app) has Secure Input on, which blocks every keyboard shortcut app. It goes off when you leave the password field, or quit \\(app).\", theme: .warning)\n            }\n        }\n    }\n\n    var panel: ViewContent {\n        let bindings = layer.bindings\n        PanelHeader(\"App Switcher\", subtitle: \"Hold \\(triggerKey.title) and press a letter\", symbol: \"command\", theme: theme) {\n            IconButton(\"arrow.clockwise\", label: \"Look again\", theme: theme, key: \"reload\") { try await self.refresh() }\n        }\n        statusBanner\n        if let notice {\n            Banner(notice, symbol: \"pin.fill\", theme: theme) {\n                IconButton(\"xmark\", label: \"Dismiss\", theme: theme, key: \"dismiss\") { self.notice = nil }\n            }\n        }\n        if bindings.isEmpty {\n            EmptyState(\"No letters yet\", symbol: \"keyboard\", message: \"Open some apps, or pin one: bring it to the front and press \\(triggerKey.title) \\(assignKey) and a letter.\", theme: theme)\n        } else {\n            Card(\"Letters\", detail: \"\\(bindings.count)\", spacing: 0, padding: Space.s) {\n                ForEach(bindings) { binding in self.row(binding) }\n            }\n            Text(\"Pin a letter: bring the app to the front and press \\(triggerKey.title) \\(assignKey) and the letter. Pressing it again unpins.\")\n                .role(.secondary).typography(.caption)\n        }\n    }\n\n    private func row(_ binding: KeyBinding) -> ViewContent {\n        let isPinned = pins[binding.key]?.isEmpty == false\n        let names = binding.apps.map(name(of:))\n        let running = binding.apps.filter(isRunning)\n        var detail: [String] = []\n        if isPinned { detail.append(binding.launch ? \"pinned, opens when closed\" : \"pinned\") }\n        if binding.apps.count > 1 { detail.append(\"press again for the next\") }\n        if running.isEmpty { detail.append(\"not running\") }\n        var row = Row(names.joined(separator: \", \"), subtitle: detail.isEmpty ? nil : detail.joined(separator: \" · \"), symbol: isPinned ? \"pin.fill\" : \"app\", key: \"key-\\(binding.key)\")\n            .appIcon(running.first ?? binding.apps.first)\n            .badge(binding.key.uppercased(), role: .accent)\n        if let first = running.first {\n            row = row.action(\"Switch\") { try await Stackbar.activateApp(first) }\n        } else if let first = binding.apps.first {\n            row = row.action(\"Open\") { try await Stackbar.activateApp(first, launch: true) }\n        }\n        if isPinned {\n            row = row.action(\"Unpin\") { await self.unpin(binding.key) }\n        }\n        return [row]\n    }\n\n    var settings: ViewContent {\n        Section(\"Keys\") {\n            Picker(\"Hold\", options: KeyLayerTrigger.allCases.map(\\.title), selection: triggerKey.title) { title in\n                await self.set { self.trigger = (KeyLayerTrigger.allCases.first { $0.title == title } ?? .rightCommand).rawValue }\n            }\n            Picker(\"Pressing the letter of the app in front\", options: Self.secondPressTitles.map(\\.1), selection: Self.secondPressTitles.first { $0.0 == secondPressMode }?.1 ?? \"\") { title in\n                await self.set { self.secondPress = (Self.secondPressTitles.first { $0.1 == title }?.0 ?? .hide).rawValue }\n            }\n            Toggle(\"Show the letter map while the key is held\", isOn: overlay) { value in await self.set { self.overlay = value } }\n            Text(\"Only right-hand keys are used, so the left ones keep every shortcut. Letters with no app pass through, so \\(triggerKey.title) C still copies.\").role(.secondary)\n        }\n        Section(\"Letters\") {\n            Toggle(\"Give running apps their first letter\", isOn: autoLetters) { value in await self.set { self.autoLetters = value } }\n            Toggle(\"Open pinned apps that aren't running\", isOn: openPinned) { value in await self.set { self.openPinned = value } }\n            if !pins.isEmpty {\n                Button(\"Clear every pin\", symbol: \"pin.slash\", role: .critical) {\n                    await self.set { self.pins = [:] }\n                }\n            }\n        }\n        Section(\"Permission\") {\n            Text(\"Stackbar needs Accessibility access to hear the key you hold: System Settings › Privacy & Security › Accessibility. While an app has Secure Input on (a password field), macOS blocks every keyboard listener, this one included.\").role(.secondary)\n        }\n    }\n\n    static let secondPressTitles: [(SecondPress, String)] = [(.hide, \"Hides it\"), (.cycleWindows, \"Shows its next window\"), (.stay, \"Does nothing\")]\n\n    var commands: [Command] {\n        Command(\"assign\", title: \"Pin a Letter to an App\", subtitle: \"\\(triggerKey.title) \\(assignKey) and a letter does this for the app in front\", symbol: \"pin\", keywords: [\"app switcher\", \"pin\", \"letter\", \"rcmd\"]) { invocation in\n            await self.assign(key: invocation[\"key\"] ?? \"\", app: invocation[\"app\"] ?? \"\")\n        }\n        .argument(\"key\", placeholder: \"Letter\")\n        .argument(\"app\", placeholder: \"Bundle id, like com.apple.Safari\")\n        Command(\"clear-pins\", title: \"Clear App Switcher Pins\", subtitle: \"Every letter goes back to its app's first letter\", symbol: \"pin.slash\", keywords: [\"app switcher\", \"pin\", \"letter\"]) { _ in\n            await self.set { self.pins = [:] }\n            return .message(\"Cleared every pin\")\n        }\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"letters\", description: \"The App Switcher's letter map: which app each letter switches to while the trigger key is held, pinned letters marked\") { _ in\n            try? await self.refresh()\n            let bindings = self.layer.bindings\n            guard !bindings.isEmpty else { return \"No letters\" }\n            return bindings.map { binding in\n                \"- \\(binding.key.uppercased()): \\(binding.apps.map(self.name(of:)).joined(separator: \", \"))\\(self.pins[binding.key]?.isEmpty == false ? \" (pinned)\" : \"\")\"\n            }.joined(separator: \"\\n\")\n        }\n    }\n}\n",
  "sourceDigest" : "6083048dafd21a2d850a43c661c367abcb99a8e276e7a33ad019c5938415c062"
}