@arach/lattices 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +1 -40
- package/app/Sources/AppDelegate.swift +154 -24
- package/app/Sources/CheatSheetHUD.swift +1 -0
- package/app/Sources/CommandModeState.swift +40 -19
- package/app/Sources/CommandModeView.swift +27 -2
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DiagnosticLog.swift +19 -1
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HotkeyManager.swift +1 -0
- package/app/Sources/HotkeyStore.swift +9 -1
- package/app/Sources/LatticesApi.swift +210 -0
- package/app/Sources/MainView.swift +46 -86
- package/app/Sources/MainWindow.swift +13 -0
- package/app/Sources/OcrModel.swift +309 -0
- package/app/Sources/OcrStore.swift +295 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/PaletteCommand.swift +11 -1
- package/app/Sources/PermissionChecker.swift +12 -2
- package/app/Sources/Preferences.swift +44 -0
- package/app/Sources/ScreenMapState.swift +7 -17
- package/app/Sources/ScreenMapView.swift +3 -0
- package/app/Sources/SettingsView.swift +534 -122
- package/app/Sources/Theme.swift +39 -0
- package/app/Sources/WindowTiler.swift +59 -56
- package/bin/lattices-app.js +23 -7
- package/bin/lattices.js +123 -0
- package/docs/api.md +390 -249
- package/docs/app.md +75 -28
- package/docs/concepts.md +45 -136
- package/docs/config.md +8 -7
- package/docs/layers.md +16 -18
- package/docs/ocr.md +185 -0
- package/docs/overview.md +39 -34
- package/docs/quickstart.md +34 -35
- package/package.json +6 -2
|
@@ -67,18 +67,27 @@ final class DiagnosticWindow {
|
|
|
67
67
|
static let shared = DiagnosticWindow()
|
|
68
68
|
|
|
69
69
|
private var window: NSWindow?
|
|
70
|
+
private var keyMonitor: Any?
|
|
70
71
|
private let log = DiagnosticLog.shared
|
|
71
72
|
|
|
72
73
|
var isVisible: Bool { window?.isVisible ?? false }
|
|
73
74
|
|
|
74
75
|
func toggle() {
|
|
75
76
|
if let w = window, w.isVisible {
|
|
76
|
-
|
|
77
|
+
dismiss()
|
|
77
78
|
} else {
|
|
78
79
|
show()
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
func dismiss() {
|
|
84
|
+
window?.orderOut(nil)
|
|
85
|
+
if let monitor = keyMonitor {
|
|
86
|
+
NSEvent.removeMonitor(monitor)
|
|
87
|
+
keyMonitor = nil
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
func show() {
|
|
83
92
|
if let w = window {
|
|
84
93
|
w.orderFrontRegardless()
|
|
@@ -119,6 +128,15 @@ final class DiagnosticWindow {
|
|
|
119
128
|
w.orderFrontRegardless()
|
|
120
129
|
window = w
|
|
121
130
|
|
|
131
|
+
// Escape key → dismiss
|
|
132
|
+
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
133
|
+
guard event.keyCode == 53,
|
|
134
|
+
let win = self?.window,
|
|
135
|
+
event.window === win || win.isKeyWindow else { return event }
|
|
136
|
+
self?.dismiss()
|
|
137
|
+
return nil
|
|
138
|
+
}
|
|
139
|
+
|
|
122
140
|
// Startup log
|
|
123
141
|
let diag = DiagnosticLog.shared
|
|
124
142
|
diag.info("Diagnostics opened")
|
|
@@ -18,6 +18,8 @@ enum HotkeyAction: String, CaseIterable, Codable {
|
|
|
18
18
|
case screenMap
|
|
19
19
|
case bezel
|
|
20
20
|
case cheatSheet
|
|
21
|
+
case desktopInventory
|
|
22
|
+
case omniSearch
|
|
21
23
|
// Layers
|
|
22
24
|
case layer1, layer2, layer3, layer4, layer5, layer6, layer7, layer8, layer9
|
|
23
25
|
// Tiling
|
|
@@ -32,6 +34,8 @@ enum HotkeyAction: String, CaseIterable, Codable {
|
|
|
32
34
|
case .screenMap: return "Screen Map"
|
|
33
35
|
case .bezel: return "Window Bezel"
|
|
34
36
|
case .cheatSheet: return "Cheat Sheet"
|
|
37
|
+
case .desktopInventory: return "Desktop Inventory"
|
|
38
|
+
case .omniSearch: return "Omni Search"
|
|
35
39
|
case .layer1: return "Layer 1"
|
|
36
40
|
case .layer2: return "Layer 2"
|
|
37
41
|
case .layer3: return "Layer 3"
|
|
@@ -60,7 +64,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
|
|
|
60
64
|
|
|
61
65
|
var group: HotkeyGroup {
|
|
62
66
|
switch self {
|
|
63
|
-
case .palette, .screenMap, .bezel, .cheatSheet: return .app
|
|
67
|
+
case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch: return .app
|
|
64
68
|
case .layer1, .layer2, .layer3, .layer4, .layer5,
|
|
65
69
|
.layer6, .layer7, .layer8, .layer9: return .layers
|
|
66
70
|
default: return .tiling
|
|
@@ -73,6 +77,8 @@ enum HotkeyAction: String, CaseIterable, Codable {
|
|
|
73
77
|
case .screenMap: return 200
|
|
74
78
|
case .bezel: return 201
|
|
75
79
|
case .cheatSheet: return 202
|
|
80
|
+
case .desktopInventory: return 203
|
|
81
|
+
case .omniSearch: return 204
|
|
76
82
|
case .layer1: return 101
|
|
77
83
|
case .layer2: return 102
|
|
78
84
|
case .layer3: return 103
|
|
@@ -192,6 +198,8 @@ class HotkeyStore: ObservableObject {
|
|
|
192
198
|
bind(.screenMap, 18, hyper) // Hyper+1
|
|
193
199
|
bind(.bezel, 19, hyper) // Hyper+2
|
|
194
200
|
bind(.cheatSheet, 20, hyper) // Hyper+3
|
|
201
|
+
bind(.desktopInventory, 21, hyper) // Hyper+4
|
|
202
|
+
bind(.omniSearch, 23, hyper) // Hyper+5
|
|
195
203
|
|
|
196
204
|
// Layers: Cmd+Option+1-9
|
|
197
205
|
let layerKeyCodes: [UInt32] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
|
|
@@ -244,6 +244,36 @@ final class LatticesApi {
|
|
|
244
244
|
Field(name: "displayName", type: "string", required: true, description: "Best display name (session > tab title > tty)"),
|
|
245
245
|
]))
|
|
246
246
|
|
|
247
|
+
api.model(ApiModel(name: "OcrResult", fields: [
|
|
248
|
+
Field(name: "wid", type: "int", required: true, description: "Window ID"),
|
|
249
|
+
Field(name: "app", type: "string", required: true, description: "Application name"),
|
|
250
|
+
Field(name: "title", type: "string", required: true, description: "Window title"),
|
|
251
|
+
Field(name: "frame", type: "Frame", required: true, description: "Window frame"),
|
|
252
|
+
Field(name: "fullText", type: "string", required: true, description: "All recognized text"),
|
|
253
|
+
Field(name: "blocks", type: "[OcrBlock]", required: true, description: "Individual text blocks with position/confidence"),
|
|
254
|
+
Field(name: "timestamp", type: "double", required: true, description: "Scan timestamp (Unix)"),
|
|
255
|
+
]))
|
|
256
|
+
|
|
257
|
+
api.model(ApiModel(name: "OcrBlock", fields: [
|
|
258
|
+
Field(name: "text", type: "string", required: true, description: "Recognized text"),
|
|
259
|
+
Field(name: "confidence", type: "double", required: true, description: "Recognition confidence 0-1"),
|
|
260
|
+
Field(name: "x", type: "double", required: true, description: "Normalized bounding box x"),
|
|
261
|
+
Field(name: "y", type: "double", required: true, description: "Normalized bounding box y"),
|
|
262
|
+
Field(name: "w", type: "double", required: true, description: "Normalized bounding box width"),
|
|
263
|
+
Field(name: "h", type: "double", required: true, description: "Normalized bounding box height"),
|
|
264
|
+
]))
|
|
265
|
+
|
|
266
|
+
api.model(ApiModel(name: "OcrSearchResult", fields: [
|
|
267
|
+
Field(name: "id", type: "int", required: true, description: "Database row ID"),
|
|
268
|
+
Field(name: "wid", type: "int", required: true, description: "Window ID"),
|
|
269
|
+
Field(name: "app", type: "string", required: true, description: "Application name"),
|
|
270
|
+
Field(name: "title", type: "string", required: true, description: "Window title"),
|
|
271
|
+
Field(name: "frame", type: "Frame", required: true, description: "Window frame at scan time"),
|
|
272
|
+
Field(name: "fullText", type: "string", required: true, description: "Full recognized text"),
|
|
273
|
+
Field(name: "snippet", type: "string", required: true, description: "Highlighted snippet (FTS5)"),
|
|
274
|
+
Field(name: "timestamp", type: "double", required: true, description: "Scan timestamp (Unix)"),
|
|
275
|
+
]))
|
|
276
|
+
|
|
247
277
|
api.model(ApiModel(name: "DaemonStatus", fields: [
|
|
248
278
|
Field(name: "uptime", type: "double", required: true, description: "Seconds since daemon started"),
|
|
249
279
|
Field(name: "clientCount", type: "int", required: true, description: "Connected WebSocket clients"),
|
|
@@ -283,6 +313,63 @@ final class LatticesApi {
|
|
|
283
313
|
}
|
|
284
314
|
))
|
|
285
315
|
|
|
316
|
+
api.register(Endpoint(
|
|
317
|
+
method: "windows.search",
|
|
318
|
+
description: "Search windows by title, app, and OCR content",
|
|
319
|
+
access: .read,
|
|
320
|
+
params: [
|
|
321
|
+
Param(name: "query", type: "string", required: true, description: "Search text"),
|
|
322
|
+
Param(name: "ocr", type: "bool", required: false, description: "Include OCR content (default true)"),
|
|
323
|
+
Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
|
|
324
|
+
],
|
|
325
|
+
returns: .array(model: "Window"),
|
|
326
|
+
handler: { params in
|
|
327
|
+
guard let query = params?["query"]?.stringValue?.lowercased(), !query.isEmpty else {
|
|
328
|
+
throw RouterError.missingParam("query")
|
|
329
|
+
}
|
|
330
|
+
let includeOcr = params?["ocr"]?.boolValue ?? true
|
|
331
|
+
let limit = params?["limit"]?.intValue ?? 50
|
|
332
|
+
let ocrResults = OcrModel.shared.results
|
|
333
|
+
|
|
334
|
+
var matches: [JSON] = []
|
|
335
|
+
for entry in DesktopModel.shared.allWindows() {
|
|
336
|
+
let matchesApp = entry.app.lowercased().contains(query)
|
|
337
|
+
let matchesTitle = entry.title.lowercased().contains(query)
|
|
338
|
+
let matchesSession = entry.latticesSession?.lowercased().contains(query) ?? false
|
|
339
|
+
let ocrText = includeOcr ? ocrResults[entry.wid]?.fullText : nil
|
|
340
|
+
let matchesOcrContent = ocrText?.lowercased().contains(query) ?? false
|
|
341
|
+
|
|
342
|
+
if matchesApp || matchesTitle || matchesSession || matchesOcrContent {
|
|
343
|
+
var obj = Encoders.window(entry)
|
|
344
|
+
if matchesOcrContent, let text = ocrText,
|
|
345
|
+
let range = text.lowercased().range(of: query) {
|
|
346
|
+
// Extract snippet around match
|
|
347
|
+
let half = max(0, (80 - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
|
|
348
|
+
let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
|
|
349
|
+
let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
|
|
350
|
+
var snippet = String(text[start..<end])
|
|
351
|
+
.replacingOccurrences(of: "\n", with: " ")
|
|
352
|
+
.trimmingCharacters(in: .whitespaces)
|
|
353
|
+
if start > text.startIndex { snippet = "…" + snippet }
|
|
354
|
+
if end < text.endIndex { snippet += "…" }
|
|
355
|
+
if case .object(var dict) = obj {
|
|
356
|
+
dict["ocrSnippet"] = .string(snippet)
|
|
357
|
+
dict["matchSource"] = .string("ocr")
|
|
358
|
+
obj = .object(dict)
|
|
359
|
+
}
|
|
360
|
+
} else if case .object(var dict) = obj {
|
|
361
|
+
let source = matchesTitle ? "title" : matchesApp ? "app" : "session"
|
|
362
|
+
dict["matchSource"] = .string(source)
|
|
363
|
+
obj = .object(dict)
|
|
364
|
+
}
|
|
365
|
+
matches.append(obj)
|
|
366
|
+
if matches.count >= limit { break }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return .array(matches)
|
|
370
|
+
}
|
|
371
|
+
))
|
|
372
|
+
|
|
286
373
|
api.register(Endpoint(
|
|
287
374
|
method: "tmux.sessions",
|
|
288
375
|
description: "List all tmux sessions with child process enrichment",
|
|
@@ -486,6 +573,85 @@ final class LatticesApi {
|
|
|
486
573
|
}
|
|
487
574
|
))
|
|
488
575
|
|
|
576
|
+
// ── Endpoints: OCR ─────────────────────────────────────
|
|
577
|
+
|
|
578
|
+
api.register(Endpoint(
|
|
579
|
+
method: "ocr.snapshot",
|
|
580
|
+
description: "Get the latest OCR scan results for all on-screen windows",
|
|
581
|
+
access: .read,
|
|
582
|
+
params: [],
|
|
583
|
+
returns: .array(model: "OcrResult"),
|
|
584
|
+
handler: { _ in
|
|
585
|
+
let results = OcrModel.shared.results
|
|
586
|
+
return .array(results.values.map { Encoders.ocrResult($0) })
|
|
587
|
+
}
|
|
588
|
+
))
|
|
589
|
+
|
|
590
|
+
api.register(Endpoint(
|
|
591
|
+
method: "ocr.search",
|
|
592
|
+
description: "Search OCR text across all windows (queries persistent SQLite FTS5 index by default)",
|
|
593
|
+
access: .read,
|
|
594
|
+
params: [
|
|
595
|
+
Param(name: "query", type: "string", required: true, description: "Search text (FTS5 query syntax)"),
|
|
596
|
+
Param(name: "app", type: "string", required: false, description: "Filter by app name"),
|
|
597
|
+
Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
|
|
598
|
+
Param(name: "live", type: "bool", required: false, description: "Search in-memory snapshot instead of history (default false)"),
|
|
599
|
+
],
|
|
600
|
+
returns: .array(model: "OcrSearchResult"),
|
|
601
|
+
handler: { params in
|
|
602
|
+
guard let query = params?["query"]?.stringValue else {
|
|
603
|
+
throw RouterError.missingParam("query")
|
|
604
|
+
}
|
|
605
|
+
let app = params?["app"]?.stringValue
|
|
606
|
+
let limit = params?["limit"]?.intValue ?? 50
|
|
607
|
+
let live = params?["live"]?.boolValue ?? false
|
|
608
|
+
|
|
609
|
+
if live {
|
|
610
|
+
// In-memory snapshot search (original behavior)
|
|
611
|
+
var results = Array(OcrModel.shared.results.values)
|
|
612
|
+
let q = query.lowercased()
|
|
613
|
+
results = results.filter { $0.fullText.lowercased().contains(q) }
|
|
614
|
+
if let app { results = results.filter { $0.app == app } }
|
|
615
|
+
return .array(results.prefix(limit).map { Encoders.ocrResult($0) })
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Persistent FTS5 search
|
|
619
|
+
let results = OcrStore.shared.search(query: query, app: app, limit: limit)
|
|
620
|
+
return .array(results.map { Encoders.ocrSearchResult($0) })
|
|
621
|
+
}
|
|
622
|
+
))
|
|
623
|
+
|
|
624
|
+
api.register(Endpoint(
|
|
625
|
+
method: "ocr.history",
|
|
626
|
+
description: "Get OCR content timeline for a specific window",
|
|
627
|
+
access: .read,
|
|
628
|
+
params: [
|
|
629
|
+
Param(name: "wid", type: "uint32", required: true, description: "Window ID"),
|
|
630
|
+
Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
|
|
631
|
+
],
|
|
632
|
+
returns: .array(model: "OcrSearchResult"),
|
|
633
|
+
handler: { params in
|
|
634
|
+
guard let wid = params?["wid"]?.uint32Value else {
|
|
635
|
+
throw RouterError.missingParam("wid")
|
|
636
|
+
}
|
|
637
|
+
let limit = params?["limit"]?.intValue ?? 50
|
|
638
|
+
let results = OcrStore.shared.history(wid: wid, limit: limit)
|
|
639
|
+
return .array(results.map { Encoders.ocrSearchResult($0) })
|
|
640
|
+
}
|
|
641
|
+
))
|
|
642
|
+
|
|
643
|
+
api.register(Endpoint(
|
|
644
|
+
method: "ocr.scan",
|
|
645
|
+
description: "Trigger an immediate OCR scan",
|
|
646
|
+
access: .mutate,
|
|
647
|
+
params: [],
|
|
648
|
+
returns: .ok,
|
|
649
|
+
handler: { _ in
|
|
650
|
+
OcrModel.shared.scan()
|
|
651
|
+
return .object(["ok": .bool(true)])
|
|
652
|
+
}
|
|
653
|
+
))
|
|
654
|
+
|
|
489
655
|
// ── Endpoints: Mutations ────────────────────────────────
|
|
490
656
|
|
|
491
657
|
api.register(Endpoint(
|
|
@@ -857,6 +1023,50 @@ enum Encoders {
|
|
|
857
1023
|
return .object(obj)
|
|
858
1024
|
}
|
|
859
1025
|
|
|
1026
|
+
static func ocrResult(_ r: OcrWindowResult) -> JSON {
|
|
1027
|
+
.object([
|
|
1028
|
+
"wid": .int(Int(r.wid)),
|
|
1029
|
+
"app": .string(r.app),
|
|
1030
|
+
"title": .string(r.title),
|
|
1031
|
+
"frame": .object([
|
|
1032
|
+
"x": .double(r.frame.x),
|
|
1033
|
+
"y": .double(r.frame.y),
|
|
1034
|
+
"w": .double(r.frame.w),
|
|
1035
|
+
"h": .double(r.frame.h)
|
|
1036
|
+
]),
|
|
1037
|
+
"fullText": .string(r.fullText),
|
|
1038
|
+
"blocks": .array(r.texts.map { block in
|
|
1039
|
+
.object([
|
|
1040
|
+
"text": .string(block.text),
|
|
1041
|
+
"confidence": .double(Double(block.confidence)),
|
|
1042
|
+
"x": .double(block.boundingBox.origin.x),
|
|
1043
|
+
"y": .double(block.boundingBox.origin.y),
|
|
1044
|
+
"w": .double(block.boundingBox.size.width),
|
|
1045
|
+
"h": .double(block.boundingBox.size.height)
|
|
1046
|
+
])
|
|
1047
|
+
}),
|
|
1048
|
+
"timestamp": .double(r.timestamp.timeIntervalSince1970)
|
|
1049
|
+
])
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
static func ocrSearchResult(_ r: OcrSearchResult) -> JSON {
|
|
1053
|
+
.object([
|
|
1054
|
+
"id": .int(Int(r.id)),
|
|
1055
|
+
"wid": .int(Int(r.wid)),
|
|
1056
|
+
"app": .string(r.app),
|
|
1057
|
+
"title": .string(r.title),
|
|
1058
|
+
"frame": .object([
|
|
1059
|
+
"x": .double(r.frame.x),
|
|
1060
|
+
"y": .double(r.frame.y),
|
|
1061
|
+
"w": .double(r.frame.w),
|
|
1062
|
+
"h": .double(r.frame.h)
|
|
1063
|
+
]),
|
|
1064
|
+
"fullText": .string(r.fullText),
|
|
1065
|
+
"snippet": .string(r.snippet),
|
|
1066
|
+
"timestamp": .double(r.timestamp.timeIntervalSince1970)
|
|
1067
|
+
])
|
|
1068
|
+
}
|
|
1069
|
+
|
|
860
1070
|
static func enrichedSession(_ s: TmuxSession) -> JSON {
|
|
861
1071
|
let pm = ProcessModel.shared
|
|
862
1072
|
return .object([
|
|
@@ -71,16 +71,7 @@ struct MainView: View {
|
|
|
71
71
|
Spacer()
|
|
72
72
|
|
|
73
73
|
headerButton(icon: "arrow.up.left.and.arrow.down.right") {
|
|
74
|
-
|
|
75
|
-
for window in NSApp.windows {
|
|
76
|
-
if window is NSPanel, window.isVisible,
|
|
77
|
-
!CommandPaletteWindow.shared.isVisible || window.frame.width < 500 {
|
|
78
|
-
// MenuBarExtra panels are small (~380px); command palette is 540px
|
|
79
|
-
if window.frame.width <= 400 {
|
|
80
|
-
window.orderOut(nil)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
74
|
+
(NSApp.delegate as? AppDelegate)?.dismissPopover()
|
|
84
75
|
MainWindow.shared.show()
|
|
85
76
|
}
|
|
86
77
|
headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
|
|
@@ -190,8 +181,8 @@ struct MainView: View {
|
|
|
190
181
|
.fill(Palette.border)
|
|
191
182
|
.frame(height: 0.5)
|
|
192
183
|
|
|
193
|
-
//
|
|
194
|
-
|
|
184
|
+
// Actions footer
|
|
185
|
+
actionsSection
|
|
195
186
|
}
|
|
196
187
|
}
|
|
197
188
|
|
|
@@ -253,96 +244,65 @@ struct MainView: View {
|
|
|
253
244
|
}
|
|
254
245
|
}
|
|
255
246
|
|
|
256
|
-
// MARK: -
|
|
247
|
+
// MARK: - Actions footer
|
|
257
248
|
|
|
258
|
-
private var
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
Image(systemName: "gearshape")
|
|
263
|
-
.font(.system(size: 10, weight: .medium))
|
|
264
|
-
.foregroundColor(Palette.textMuted)
|
|
249
|
+
private var actionsSection: some View {
|
|
250
|
+
VStack(spacing: 0) {
|
|
251
|
+
ActionRow(shortcut: "1", label: "Command Palette", hotkey: hotkeyLabel(.palette), icon: "command", accentColor: Palette.running) {
|
|
252
|
+
CommandPaletteWindow.shared.toggle()
|
|
265
253
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
254
|
+
ActionRow(shortcut: "2", label: "Screen Map", hotkey: hotkeyLabel(.screenMap), icon: "rectangle.3.group") {
|
|
255
|
+
ScreenMapWindowController.shared.toggle()
|
|
256
|
+
}
|
|
257
|
+
ActionRow(shortcut: "3", label: "Desktop Inventory", hotkey: hotkeyLabel(.desktopInventory), icon: "rectangle.split.2x1") {
|
|
258
|
+
CommandModeWindow.shared.toggle()
|
|
259
|
+
}
|
|
260
|
+
ActionRow(shortcut: "4", label: "Window Bezel", hotkey: hotkeyLabel(.bezel), icon: "macwindow") {
|
|
261
|
+
WindowBezel.showBezelForFrontmostWindow()
|
|
262
|
+
}
|
|
263
|
+
ActionRow(shortcut: "5", label: "Cheat Sheet", hotkey: hotkeyLabel(.cheatSheet), icon: "keyboard") {
|
|
264
|
+
CheatSheetHUD.shared.toggle()
|
|
265
|
+
}
|
|
266
|
+
ActionRow(shortcut: "6", label: "Omni Search", hotkey: hotkeyLabel(.omniSearch), icon: "magnifyingglass", accentColor: Palette.running) {
|
|
267
|
+
OmniSearchWindow.shared.toggle()
|
|
281
268
|
}
|
|
282
|
-
.buttonStyle(.plain)
|
|
283
|
-
.help(!permChecker.allGranted ? "Permissions missing — open diagnostics" : "Toggle diagnostics")
|
|
284
269
|
|
|
285
270
|
Rectangle()
|
|
286
271
|
.fill(Palette.border)
|
|
287
|
-
.frame(
|
|
288
|
-
.padding(.horizontal,
|
|
289
|
-
|
|
290
|
-
// Config summary — keys dim, values white
|
|
291
|
-
statusLine
|
|
292
|
-
|
|
293
|
-
Spacer()
|
|
272
|
+
.frame(height: 0.5)
|
|
273
|
+
.padding(.horizontal, 10)
|
|
294
274
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
275
|
+
ActionRow(shortcut: "S", label: "Settings", icon: "gearshape") {
|
|
276
|
+
SettingsWindowController.shared.show()
|
|
277
|
+
}
|
|
278
|
+
HStack(spacing: 0) {
|
|
279
|
+
ActionRow(shortcut: "D", label: "Diagnostics", icon: "stethoscope") {
|
|
280
|
+
DiagnosticWindow.shared.toggle()
|
|
281
|
+
}
|
|
282
|
+
if !permChecker.allGranted {
|
|
283
|
+
Circle()
|
|
284
|
+
.fill(Palette.detach)
|
|
285
|
+
.frame(width: 6, height: 6)
|
|
286
|
+
.padding(.trailing, 14)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
300
289
|
|
|
301
290
|
Rectangle()
|
|
302
291
|
.fill(Palette.border)
|
|
303
|
-
.frame(
|
|
304
|
-
.padding(.horizontal,
|
|
292
|
+
.frame(height: 0.5)
|
|
293
|
+
.padding(.horizontal, 10)
|
|
305
294
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
Image(systemName: "power")
|
|
309
|
-
.font(.system(size: 9, weight: .medium))
|
|
310
|
-
.foregroundColor(Palette.textMuted)
|
|
295
|
+
ActionRow(shortcut: "Q", label: "Quit", icon: "power", accentColor: Palette.kill) {
|
|
296
|
+
NSApp.terminate(nil)
|
|
311
297
|
}
|
|
312
|
-
.buttonStyle(.plain)
|
|
313
|
-
.help("Quit lattices")
|
|
314
298
|
}
|
|
315
|
-
.padding(.
|
|
316
|
-
.padding(.vertical, 7)
|
|
299
|
+
.padding(.vertical, 4)
|
|
317
300
|
.background(Palette.surface.opacity(0.4))
|
|
318
301
|
}
|
|
319
302
|
|
|
320
|
-
private
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
statusDot
|
|
324
|
-
statusPair("mode", prefs.mode.rawValue)
|
|
325
|
-
statusDot
|
|
326
|
-
statusPair("home", "~/\((prefs.scanRoot as NSString).lastPathComponent)")
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private func statusPair(_ key: String, _ value: String) -> some View {
|
|
331
|
-
HStack(spacing: 3) {
|
|
332
|
-
Text(key + ":")
|
|
333
|
-
.font(Typo.mono(9))
|
|
334
|
-
.foregroundColor(Palette.textMuted)
|
|
335
|
-
Text(value)
|
|
336
|
-
.font(Typo.mono(9))
|
|
337
|
-
.foregroundColor(Palette.text)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
private var statusDot: some View {
|
|
342
|
-
Circle()
|
|
343
|
-
.fill(Palette.textMuted)
|
|
344
|
-
.frame(width: 2, height: 2)
|
|
345
|
-
.padding(.horizontal, 4)
|
|
303
|
+
private func hotkeyLabel(_ action: HotkeyAction) -> String? {
|
|
304
|
+
guard let binding = HotkeyStore.shared.bindings[action] else { return nil }
|
|
305
|
+
return binding.displayParts.joined(separator: "")
|
|
346
306
|
}
|
|
347
307
|
|
|
348
308
|
// MARK: - Empty state
|
|
@@ -7,6 +7,7 @@ final class MainWindow {
|
|
|
7
7
|
static let shared = MainWindow()
|
|
8
8
|
|
|
9
9
|
private var window: NSWindow?
|
|
10
|
+
private var keyMonitor: Any?
|
|
10
11
|
|
|
11
12
|
var isVisible: Bool { window?.isVisible ?? false }
|
|
12
13
|
|
|
@@ -61,10 +62,22 @@ final class MainWindow {
|
|
|
61
62
|
|
|
62
63
|
window = w
|
|
63
64
|
AppDelegate.updateActivationPolicy()
|
|
65
|
+
|
|
66
|
+
// Escape key → close
|
|
67
|
+
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
68
|
+
guard event.keyCode == 53,
|
|
69
|
+
self?.window?.isKeyWindow == true else { return event }
|
|
70
|
+
self?.close()
|
|
71
|
+
return nil
|
|
72
|
+
}
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
func close() {
|
|
67
76
|
window?.orderOut(nil)
|
|
77
|
+
if let monitor = keyMonitor {
|
|
78
|
+
NSEvent.removeMonitor(monitor)
|
|
79
|
+
keyMonitor = nil
|
|
80
|
+
}
|
|
68
81
|
AppDelegate.updateActivationPolicy()
|
|
69
82
|
}
|
|
70
83
|
}
|