{
  "format" : 1,
  "manifest" : {
    "accent" : "teal",
    "capabilities" : [
      {
        "menus" : {

        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/System/MenuSearch",
    "id" : "io.navbytes.stackbar.plugins.menu-search",
    "minimumStackbar" : "0.31.0",
    "name" : "Menu Search",
    "summary" : "Search the menus of the app you're in and choose an item without the mouse.",
    "surfaces" : [
      "panel"
    ],
    "symbol" : "filemenu.and.selection",
    "triggers" : [
      {
        "launch" : {

        }
      }
    ],
    "version" : "1.1.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "PSKqVJ6AiZWlt2J0VVTTofkGtiG6hSTgra/SbIciZiqs+45ZhR5RXWziiIuw08kp9hvBgJbtPvcdNS5PuiV1AQ==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// The menu bar of the app you're in, searchable from the palette, as\n/// Raycast's Search Menu Items and macOS's own Help menu search have it:\n/// type \"export\" or \"zoom\" and Return chooses the item, so nothing in a\n/// menu is more than a few letters away. Shortcuts show beside each item,\n/// which is a quick way to learn them.\n@main\nfinal class MenuSearch: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.menu-search\",\n        name: \"Menu Search\",\n        version: \"1.1.0\",\n        summary: \"Search the menus of the app you're in and choose an item without the mouse.\",\n        surfaces: [.panel],\n        triggers: [.launch],\n        // Reading the front app's menus and choosing an item.\n        capabilities: [.menus],\n        minimumStackbar: \"0.31.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/System/MenuSearch\",\n        accent: .teal,\n        symbol: \"filemenu.and.selection\"\n    )\n\n    static let previews: [PreviewScene<MenuSearch>] = [\n        PreviewScene(\"hero\", \"Your most used menu items, one click away\") { plugin in\n            plugin.chosen = [\"com.apple.Safari|File\\u{1f}New Private Window\": 9, \"com.apple.dt.Xcode|Product\\u{1f}Clean Build Folder…\": 7,\n                             \"com.apple.Safari|View\\u{1f}Zoom In\": 4, \"com.apple.Notes|Format\\u{1f}Checklist\": 3]\n        },\n        PreviewScene(\"first-run\", \"Every menu, a few letters away\") { _ in },\n    ]\n\n    let theme = Theme.teal\n\n    var menus: MenuSnapshot?\n    var readAt: Date?\n    var problem: String?\n    var results: [MenuItemInfo] = []\n    /// How often each item was chosen, by app id and path.\n    @Stored(\"chosen\") var chosen: [String: Int] = [:]\n\n    /// Reads the menus when the view opens (nothing typed yet) or the last\n    /// read is old: walking a big app's menus takes a moment, so typing\n    /// reuses it.\n    func load(force: Bool) async {\n        // A failed read is remembered too (for a shorter while), so a broken\n        // state doesn't repeat the walk on every keystroke.\n        if !force, let readAt, Date().timeIntervalSince(readAt) < (menus != nil ? 30 : 5) { return }\n        do {\n            menus = try await Stackbar.menuItems()\n            readAt = Date()\n            problem = nil\n        } catch {\n            menus = nil\n            readAt = Date()\n            problem = \"Can't read the menus: \\(error)\"\n        }\n    }\n\n    // MARK: Matching\n\n    static func key(_ appID: String, _ item: MenuItemInfo) -> String {\n        \"\\(appID)|\\(item.path.joined(separator: \"\\u{1f}\"))\"\n    }\n\n    /// How well `typed` matches `text`, or nil: the start beats the start of\n    /// a word, which beats anywhere. Initials (\"nw\") and letters in order\n    /// (\"prvate\") come last, so a typo still finds the item.\n    static func score(_ typed: String, in text: String) -> Int? {\n        let text = text.lowercased()\n        if text == typed { return 1200 }\n        if text.hasPrefix(typed) { return 1000 - min(text.count, 100) }\n        let words = text.split(whereSeparator: { !$0.isLetter && !$0.isNumber }).map(String.init)\n        if words.contains(where: { $0.hasPrefix(typed) }) { return 800 - min(text.count, 100) }\n        if text.contains(typed) { return 500 - min(text.count, 100) }\n        if let match = TextMatch.match(typed, in: text) {\n            switch match.tier {\n            case .initials: return 450 - min(text.count, 100)\n            case .inOrder: return 150 - min(text.count, 100)\n            default: break\n            }\n        }\n        return nil\n    }\n\n    /// The items matching `query`, best first. Every word typed must match\n    /// the item's title or a menu above it, with the title worth more, so\n    /// \"view zoom\" finds View › Zoom In. Dimmed items sink to the bottom.\n    static func matches(_ query: String, in menus: MenuSnapshot, chosen: [String: Int] = [:], limit: Int = 60) -> [MenuItemInfo] {\n        // \"export...\" is \"export\": dots and ellipses trail a name, they aren't in it.\n        let words = query.lowercased().split(separator: \" \").map { $0.trimmingCharacters(in: CharacterSet(charactersIn: \".…\")) }.filter { !$0.isEmpty }\n        guard !words.isEmpty else {\n            return query.trimmingCharacters(in: .whitespaces).isEmpty ? Array(menus.items.prefix(limit)) : []\n        }\n        return menus.items.enumerated().compactMap { index, item -> (MenuItemInfo, Int, Int)? in\n            var total = 0\n            for word in words {\n                let own = score(word, in: item.title)\n                let above = item.path.dropLast().compactMap { score(word, in: $0) }.max().map { $0 / 2 }\n                guard let best = [own, above].compactMap({ $0 }).max() else { return nil }\n                total += best\n            }\n            let used = min(300, Int(60 * log2(1 + Double(chosen[key(menus.appID, item)] ?? 0))))\n            return (item, total + used - (item.isEnabled ? 0 : 2000), index)\n        }\n        // Menu order on a tie, as the app arranged them.\n        .sorted { $0.1 != $1.1 ? $0.1 > $1.1 : $0.2 < $1.2 }\n        .prefix(limit)\n        .map(\\.0)\n    }\n\n    // MARK: Choosing\n\n    func press(_ item: MenuItemInfo, in menus: MenuSnapshot) async throws {\n        chosen[Self.key(menus.appID, item), default: 0] += 1\n        try await Stackbar.pressMenuItem(appID: menus.appID, path: item.path)\n    }\n\n    func row(_ item: MenuItemInfo, in menus: MenuSnapshot) -> Row {\n        let symbol = item.isChecked ? \"checkmark\" : \"filemenu.and.selection\"\n        var row = Row(item.title, subtitle: item.isEnabled ? item.location : \"\\(item.location) · dimmed\", symbol: symbol, key: item.path.joined(separator: \"\\u{1f}\"))\n            .appIcon(menus.appID)\n        if let shortcut = item.shortcut { row = row.badge(shortcut, role: .secondary) }\n        if item.isEnabled {\n            row = row.action(\"Choose\") { try await self.press(item, in: menus) }\n        }\n        return row.action(\"Copy Path\", shortcut: \"cmd+shift+c\") { Stackbar.copy(item.path.joined(separator: \" › \")) }\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"search\", title: \"Search Menu Items\", subtitle: \"Choose any menu item of the app you're in\", symbol: \"filemenu.and.selection\", keywords: [\"menu\", \"command\", \"action\", \"help\"])\n            .view(search: { invocation in\n                await self.load(force: invocation.text.isEmpty)\n                self.results = self.menus.map { Self.matches(invocation.text, in: $0, chosen: self.chosen) } ?? []\n            }) { _ in\n                if let problem = self.problem {\n                    ContentUnavailableView(\"No menus\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else if let menus = self.menus, !self.results.isEmpty {\n                    List {\n                        Section(menus.appName) {\n                            ForEach(self.results) { item in self.row(item, in: menus) }\n                        }\n                    }\n                } else {\n                    ContentUnavailableView(\"No items match\", symbol: \"filemenu.and.selection\", message: \"Type part of a menu item, or the menu it's in.\")\n                }\n            }\n    }\n\n    // MARK: Panel\n\n    /// One of the most chosen items; `key` is the one `chosen` counts it by.\n    struct Favourite: Identifiable {\n        var key: String\n        var appID: String\n        var path: [String]\n        var id: String { key }\n    }\n\n    /// The eight most chosen items, ties by key. A key that doesn't parse\n    /// (no app, no menu) is skipped.\n    var favourites: [Favourite] {\n        chosen.sorted { $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key }\n            .prefix(8)\n            .compactMap { key, _ in\n                guard let bar = key.firstIndex(of: \"|\") else { return nil }\n                let appID = String(key[..<bar])\n                let path = key[key.index(after: bar)...].split(separator: \"\\u{1f}\").map(String.init)\n                guard !appID.isEmpty, !path.isEmpty else { return nil }\n                return Favourite(key: key, appID: appID, path: path)\n            }\n    }\n\n    var panel: ViewContent {\n        PanelHeader(\"Menu Search\", subtitle: \"Search Menu Items in the palette\", symbol: \"filemenu.and.selection\", theme: theme)\n        let top = favourites\n        if top.isEmpty {\n            EmptyState(\"Every menu, a few letters away\", symbol: \"filemenu.and.selection\", message: \"In any app, open the palette, type Search Menu Items, then part of an item's name. Return chooses it. Stackbar needs Accessibility access for this.\", theme: theme)\n        } else {\n            Card(\"Most chosen\", spacing: 0, padding: Space.s) {\n                ForEach(top) { favourite in\n                    Row(favourite.path.last ?? \"\", subtitle: favourite.path.dropLast().joined(separator: \" › \"), symbol: \"filemenu.and.selection\", key: favourite.key)\n                        .appIcon(favourite.appID)\n                        .hiddenFromPalette()\n                        .action(\"Choose\") {\n                            self.chosen[favourite.key, default: 0] += 1\n                            try await Stackbar.pressMenuItem(appID: favourite.appID, path: favourite.path)\n                        }\n                }\n            }\n            Text(\"Search Menu Items in the palette for any other item.\").role(.secondary).typography(.caption)\n        }\n    }\n}\n",
  "sourceDigest" : "40bf97207a563a6ca7996083346cc7576a2961b18b19898f6278c8f9baad321e"
}