|
| 1 | +import AppKit |
| 2 | +import GhosttyKit |
| 3 | + |
| 4 | +extension AppDelegate: NSUserInterfaceItemSearching { |
| 5 | + func searchForItems( |
| 6 | + withSearch searchString: String, |
| 7 | + resultLimit: Int, |
| 8 | + matchedItemHandler handleMatchedItems: @escaping ([Any]) -> Void |
| 9 | + ) { |
| 10 | + let needle = searchString |
| 11 | + .trimmingCharacters(in: .whitespacesAndNewlines) |
| 12 | + .lowercased() |
| 13 | + |
| 14 | + guard !needle.isEmpty else { |
| 15 | + handleMatchedItems([]) |
| 16 | + return |
| 17 | + } |
| 18 | + |
| 19 | + // Rank matches: name prefix > name substring > description substring. |
| 20 | + // Everything else is filtered out. |
| 21 | + let ranked = Self.helpSearchCatalog.compactMap { result -> (Int, HelpSearchResult)? in |
| 22 | + if result.name.lowercased().hasPrefix(needle) { return (0, result) } |
| 23 | + if result.name.localizedCaseInsensitiveContains(needle) { return (1, result) } |
| 24 | + if result.detail.localizedCaseInsensitiveContains(needle) { return (2, result) } |
| 25 | + return nil |
| 26 | + }.sorted { lhs, rhs in |
| 27 | + if lhs.0 != rhs.0 { return lhs.0 < rhs.0 } |
| 28 | + return lhs.1.name < rhs.1.name |
| 29 | + } |
| 30 | + |
| 31 | + let matched = ranked.prefix(resultLimit).map { $0.1 } |
| 32 | + |
| 33 | + // When nothing matches, offer a fallback that opens the online docs. |
| 34 | + guard !matched.isEmpty else { |
| 35 | + handleMatchedItems([Self.docsFallback]) |
| 36 | + return |
| 37 | + } |
| 38 | + |
| 39 | + handleMatchedItems(Array(matched)) |
| 40 | + } |
| 41 | + |
| 42 | + func localizedTitles(forItem item: Any) -> [String] { |
| 43 | + guard let result = item as? HelpSearchResult else { return [] } |
| 44 | + return [result.name] |
| 45 | + } |
| 46 | + |
| 47 | + func performAction(forItem item: Any) { |
| 48 | + guard let result = item as? HelpSearchResult else { return } |
| 49 | + |
| 50 | + // The search handler can be invoked off the main thread, so make sure |
| 51 | + // we present UI on the main thread. |
| 52 | + DispatchQueue.main.async { |
| 53 | + // The fallback item just opens the docs site directly. |
| 54 | + if result.kind == .docs { |
| 55 | + if let url = URL(string: result.docsLink) { |
| 56 | + NSWorkspace.shared.open(url) |
| 57 | + } |
| 58 | + return |
| 59 | + } |
| 60 | + |
| 61 | + let alert = RichTextAlert() |
| 62 | + alert.messageText = result.name |
| 63 | + |
| 64 | + switch result.kind { |
| 65 | + case .option: |
| 66 | + if !result.valueType.isEmpty { |
| 67 | + alert.informativeText = "Type: \(result.valueType)" |
| 68 | + } |
| 69 | + case .keybind: |
| 70 | + if let shortcut = self.ghostty.config.keyboardShortcut(for: result.name)?.description { |
| 71 | + alert.informativeText = "Keybind: \(shortcut)" |
| 72 | + } |
| 73 | + case .docs: |
| 74 | + break |
| 75 | + } |
| 76 | + |
| 77 | + alert.textView.textStorage?.setAttributedString(result.attributedDetail) |
| 78 | + |
| 79 | + alert.addButton(withTitle: "OK") |
| 80 | + alert.helpLink = result.docsLink |
| 81 | + _ = alert.runModal() |
| 82 | + } |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +// MARK: - Catalog |
| 87 | + |
| 88 | +extension AppDelegate { |
| 89 | + /// A single searchable config option or keybind action, sourced from |
| 90 | + /// libghostty. Backed by an `NSObject` subclass so instances round-trip |
| 91 | + /// cleanly through the Objective-C `NSUserInterfaceItemSearching` API. |
| 92 | + fileprivate final class HelpSearchResult: NSObject { |
| 93 | + enum Kind { |
| 94 | + case option |
| 95 | + case keybind |
| 96 | + |
| 97 | + /// A synthetic fallback item shown when no options/keybinds match. |
| 98 | + case docs |
| 99 | + } |
| 100 | + |
| 101 | + let kind: Kind |
| 102 | + let name: String |
| 103 | + let detail: String |
| 104 | + let valueType: String |
| 105 | + |
| 106 | + init( |
| 107 | + kind: Kind, |
| 108 | + name: String, |
| 109 | + detail: String, |
| 110 | + valueType: String |
| 111 | + ) { |
| 112 | + self.kind = kind |
| 113 | + self.name = name |
| 114 | + self.detail = detail |
| 115 | + self.valueType = valueType |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + /// Fallback result shown when a search matches nothing; opens the docs site. |
| 120 | + fileprivate static let docsFallback = HelpSearchResult( |
| 121 | + kind: .docs, |
| 122 | + name: "Open Ghostty Documentation", |
| 123 | + detail: "", |
| 124 | + valueType: "" |
| 125 | + ) |
| 126 | + |
| 127 | + /// The full catalog of config options and keybind actions. libghostty |
| 128 | + /// returns static, process-lifetime arrays — ownership is not transferred |
| 129 | + /// and there is nothing to free. We copy each entry into Swift strings once |
| 130 | + /// and cache the resulting array for the lifetime of the app. |
| 131 | + fileprivate static let helpSearchCatalog: [HelpSearchResult] = { |
| 132 | + var results: [HelpSearchResult] = [] |
| 133 | + loadHelpSearchEntries(ghostty_config_options, kind: .option, into: &results) |
| 134 | + loadHelpSearchEntries(ghostty_config_keybind_actions, kind: .keybind, into: &results) |
| 135 | + return results |
| 136 | + }() |
| 137 | + |
| 138 | + private static func loadHelpSearchEntries( |
| 139 | + _ fetch: (UnsafeMutablePointer<UnsafePointer<ghostty_config_entry_s>?>?) -> UInt, |
| 140 | + kind: HelpSearchResult.Kind, |
| 141 | + into results: inout [HelpSearchResult] |
| 142 | + ) { |
| 143 | + var ptr: UnsafePointer<ghostty_config_entry_s>? |
| 144 | + let count = fetch(&ptr) |
| 145 | + guard let ptr, count > 0 else { return } |
| 146 | + |
| 147 | + for i in 0..<Int(count) { |
| 148 | + let entry = ptr[i] |
| 149 | + results.append(HelpSearchResult( |
| 150 | + kind: kind, |
| 151 | + name: String(cString: entry.name), |
| 152 | + detail: String(cString: entry.description), |
| 153 | + valueType: String(cString: entry.value_type) |
| 154 | + )) |
| 155 | + } |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +private extension AppDelegate.HelpSearchResult { |
| 160 | + /// Render markdown doc text into an attributed string for the alert's text |
| 161 | + /// view. AppKit doesn't render markdown presentation intents, so we parse |
| 162 | + /// inline markdown with Foundation's CommonMark parser and resolve |
| 163 | + /// emphasis/strong/code to real font traits. Bullet markers are swapped for |
| 164 | + /// a bullet glyph up front since inline parsing ignores block-level lists. |
| 165 | + var attributedDetail: NSAttributedString { |
| 166 | + let source = detail.isEmpty ? "No description available." : detail |
| 167 | + let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) |
| 168 | + |
| 169 | + let normalized = source |
| 170 | + .split(separator: "\n", omittingEmptySubsequences: false) |
| 171 | + .map { line -> Substring in |
| 172 | + let content = line.drop { $0 == " " } |
| 173 | + guard let marker = ["- ", "* "].first(where: { content.hasPrefix($0) }) else { |
| 174 | + return line |
| 175 | + } |
| 176 | + let indent = line.dropLast(content.count) |
| 177 | + return indent + "• " + content.dropFirst(marker.count) |
| 178 | + } |
| 179 | + .joined(separator: "\n") |
| 180 | + |
| 181 | + let parsed = (try? AttributedString( |
| 182 | + markdown: normalized, |
| 183 | + options: .init( |
| 184 | + interpretedSyntax: .inlineOnlyPreservingWhitespace, |
| 185 | + failurePolicy: .returnPartiallyParsedIfPossible |
| 186 | + ) |
| 187 | + )) ?? AttributedString(normalized) |
| 188 | + |
| 189 | + let result = NSMutableAttributedString() |
| 190 | + let fontManager = NSFontManager.shared |
| 191 | + for run in parsed.runs { |
| 192 | + var runFont = font |
| 193 | + if let intent = run.inlinePresentationIntent { |
| 194 | + if intent.contains(.stronglyEmphasized) { |
| 195 | + runFont = fontManager.convert(runFont, toHaveTrait: .boldFontMask) |
| 196 | + } |
| 197 | + if intent.contains(.emphasized) { |
| 198 | + runFont = fontManager.convert(runFont, toHaveTrait: .italicFontMask) |
| 199 | + } |
| 200 | + if intent.contains(.code) { |
| 201 | + runFont = .monospacedSystemFont(ofSize: font.pointSize, weight: .regular) |
| 202 | + } |
| 203 | + } |
| 204 | + |
| 205 | + var attributes: [NSAttributedString.Key: Any] = [ |
| 206 | + .font: runFont, |
| 207 | + .foregroundColor: NSColor.labelColor, |
| 208 | + ] |
| 209 | + if let link = run.link { |
| 210 | + attributes[.link] = link |
| 211 | + } |
| 212 | + |
| 213 | + result.append(NSAttributedString( |
| 214 | + string: String(parsed[run.range].characters), |
| 215 | + attributes: attributes |
| 216 | + )) |
| 217 | + } |
| 218 | + return result |
| 219 | + } |
| 220 | + |
| 221 | + var docsLink: String { |
| 222 | + switch kind { |
| 223 | + case .option: |
| 224 | + return "https://ghostty.org/docs/config/reference#\(name)" |
| 225 | + case .keybind: |
| 226 | + return "https://ghostty.org/docs/config/keybind/reference#\(name)" |
| 227 | + case .docs: |
| 228 | + return "https://ghostty.org/docs" |
| 229 | + } |
| 230 | + } |
| 231 | +} |
0 commit comments