Skip to content

Commit 84eeba9

Browse files
committed
macOS: search config&keybind in help menu
1 parent 81f2266 commit 84eeba9

4 files changed

Lines changed: 281 additions & 0 deletions

File tree

macos/Ghostty.xcodeproj/project.pbxproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
membershipExceptions = (
119119
App/macOS/AppDelegate.swift,
120120
"App/macOS/AppDelegate+Ghostty.swift",
121+
"App/macOS/AppDelegate+HelpSearch.swift",
121122
App/macOS/main.swift,
122123
App/macOS/MainMenu.xib,
123124
Features/About/About.xib,
@@ -252,6 +253,7 @@
252253
Helpers/PermissionRequest.swift,
253254
Helpers/Private/CGS.swift,
254255
Helpers/Private/Dock.swift,
256+
Helpers/RichTextAlert.swift,
255257
Helpers/TabGroupCloseCoordinator.swift,
256258
Helpers/TabTitleEditor.swift,
257259
Helpers/VibrantLayer.m,
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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+
}

macos/Sources/App/macOS/AppDelegate.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,8 @@ class AppDelegate: NSObject,
229229
// This registers the Ghostty => Services menu to exist.
230230
NSApp.servicesMenu = menuServices
231231

232+
NSApp.registerUserInterfaceItemSearchHandler(self)
233+
232234
// Setup a local event monitor for app-level keyboard shortcuts. See
233235
// localEventHandler for more info why.
234236
_ = NSEvent.addLocalMonitorForEvents(
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import AppKit
2+
3+
/// An alert with NSTextView as its accessoryView
4+
class RichTextAlert: NSAlert, NSAlertDelegate {
5+
var textView: NSTextView {
6+
guard let accessoryView else {
7+
preconditionFailure("accessoryView is nil")
8+
}
9+
guard
10+
let scrollView = accessoryView as? NSScrollView,
11+
let textView = scrollView.documentView as? NSTextView
12+
else {
13+
preconditionFailure("accessoryView doesn't contain a NSTextView")
14+
}
15+
return textView
16+
}
17+
18+
/// When set, the alert will show help button and assign the delegate to itself
19+
var helpLink: String = "" {
20+
didSet {
21+
showsHelp = true
22+
delegate = self
23+
}
24+
}
25+
26+
override init() {
27+
super.init()
28+
let accessoryView = NSTextView.scrollableTextView()
29+
accessoryView.frame = .init(x: 0, y: 0, width: 300, height: 200)
30+
if let textView = accessoryView.documentView as? NSTextView {
31+
textView.drawsBackground = false
32+
textView.isEditable = false
33+
textView.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .medium)
34+
textView.textContainerInset = .zero
35+
}
36+
self.accessoryView = accessoryView
37+
}
38+
39+
func alertShowHelp(_ alert: NSAlert) -> Bool {
40+
guard let url = URL(string: helpLink) else {
41+
return false
42+
}
43+
NSWorkspace.shared.open(url)
44+
return true
45+
}
46+
}

0 commit comments

Comments
 (0)