@lattices/cli 0.3.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 +155 -0
- package/app/Lattices.app/Contents/Info.plist +24 -0
- package/app/Package.swift +13 -0
- package/app/Sources/AccessibilityTextExtractor.swift +111 -0
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +242 -0
- package/app/Sources/AppShellView.swift +62 -0
- package/app/Sources/AppTypeClassifier.swift +70 -0
- package/app/Sources/AppWindowShell.swift +63 -0
- package/app/Sources/CheatSheetHUD.swift +332 -0
- package/app/Sources/CommandModeState.swift +1362 -0
- package/app/Sources/CommandModeView.swift +1405 -0
- package/app/Sources/CommandModeWindow.swift +192 -0
- package/app/Sources/CommandPaletteView.swift +307 -0
- package/app/Sources/CommandPaletteWindow.swift +134 -0
- package/app/Sources/DaemonProtocol.swift +101 -0
- package/app/Sources/DaemonServer.swift +414 -0
- package/app/Sources/DesktopModel.swift +149 -0
- package/app/Sources/DesktopModelTypes.swift +71 -0
- package/app/Sources/DiagnosticLog.swift +271 -0
- package/app/Sources/EventBus.swift +30 -0
- package/app/Sources/HotkeyManager.swift +254 -0
- package/app/Sources/HotkeyStore.swift +338 -0
- package/app/Sources/InventoryManager.swift +35 -0
- package/app/Sources/InventoryPath.swift +43 -0
- package/app/Sources/KeyRecorderView.swift +210 -0
- package/app/Sources/LatticesApi.swift +1234 -0
- package/app/Sources/LayerBezel.swift +203 -0
- package/app/Sources/MainView.swift +479 -0
- package/app/Sources/MainWindow.swift +83 -0
- package/app/Sources/OcrModel.swift +430 -0
- package/app/Sources/OcrStore.swift +329 -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/OrphanRow.swift +129 -0
- package/app/Sources/PaletteCommand.swift +419 -0
- package/app/Sources/PermissionChecker.swift +125 -0
- package/app/Sources/Preferences.swift +99 -0
- package/app/Sources/ProcessModel.swift +199 -0
- package/app/Sources/ProcessQuery.swift +151 -0
- package/app/Sources/Project.swift +28 -0
- package/app/Sources/ProjectRow.swift +368 -0
- package/app/Sources/ProjectScanner.swift +128 -0
- package/app/Sources/ScreenMapState.swift +2387 -0
- package/app/Sources/ScreenMapView.swift +2820 -0
- package/app/Sources/ScreenMapWindowController.swift +89 -0
- package/app/Sources/SessionManager.swift +72 -0
- package/app/Sources/SettingsView.swift +1064 -0
- package/app/Sources/SettingsWindow.swift +20 -0
- package/app/Sources/TabGroupRow.swift +178 -0
- package/app/Sources/Terminal.swift +259 -0
- package/app/Sources/TerminalQuery.swift +156 -0
- package/app/Sources/TerminalSynthesizer.swift +200 -0
- package/app/Sources/Theme.swift +163 -0
- package/app/Sources/TilePickerView.swift +209 -0
- package/app/Sources/TmuxModel.swift +53 -0
- package/app/Sources/TmuxQuery.swift +81 -0
- package/app/Sources/WindowTiler.swift +1778 -0
- package/app/Sources/WorkspaceManager.swift +575 -0
- package/bin/client.js +4 -0
- package/bin/daemon-client.js +187 -0
- package/bin/lattices-app.js +221 -0
- package/bin/lattices.js +1551 -0
- package/docs/api.md +924 -0
- package/docs/app.md +297 -0
- package/docs/concepts.md +135 -0
- package/docs/config.md +245 -0
- package/docs/layers.md +410 -0
- package/docs/ocr.md +185 -0
- package/docs/overview.md +94 -0
- package/docs/quickstart.md +75 -0
- package/package.json +42 -0
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// Settings content with internal General / Shortcuts tabs.
|
|
4
|
+
/// Can also render the Docs page when `page == .docs`.
|
|
5
|
+
struct SettingsContentView: View {
|
|
6
|
+
var page: AppPage = .settings
|
|
7
|
+
@ObservedObject var prefs: Preferences
|
|
8
|
+
@ObservedObject var scanner: ProjectScanner
|
|
9
|
+
@ObservedObject var hotkeyStore: HotkeyStore = .shared
|
|
10
|
+
var onBack: (() -> Void)? = nil
|
|
11
|
+
|
|
12
|
+
@State private var selectedTab = "shortcuts"
|
|
13
|
+
|
|
14
|
+
var body: some View {
|
|
15
|
+
VStack(spacing: 0) {
|
|
16
|
+
// Back bar
|
|
17
|
+
backBar
|
|
18
|
+
|
|
19
|
+
if page == .docs {
|
|
20
|
+
docsContent
|
|
21
|
+
} else {
|
|
22
|
+
settingsBody
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
26
|
+
.clipped()
|
|
27
|
+
.background(PanelBackground())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// MARK: - Back Bar
|
|
31
|
+
|
|
32
|
+
private var currentTabLabel: String {
|
|
33
|
+
switch selectedTab {
|
|
34
|
+
case "general": return "General"
|
|
35
|
+
case "search": return "Search & OCR"
|
|
36
|
+
case "shortcuts": return "Shortcuts"
|
|
37
|
+
default: return page.label
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private var backBar: some View {
|
|
42
|
+
VStack(spacing: 0) {
|
|
43
|
+
HStack(spacing: 8) {
|
|
44
|
+
Button {
|
|
45
|
+
onBack?()
|
|
46
|
+
} label: {
|
|
47
|
+
Image(systemName: "chevron.left")
|
|
48
|
+
.font(.system(size: 10, weight: .semibold))
|
|
49
|
+
.foregroundColor(Palette.textMuted)
|
|
50
|
+
}
|
|
51
|
+
.buttonStyle(.plain)
|
|
52
|
+
.onHover { h in if h { NSCursor.pointingHand.push() } else { NSCursor.pop() } }
|
|
53
|
+
|
|
54
|
+
Text(page == .docs ? "Docs" : currentTabLabel)
|
|
55
|
+
.font(Typo.heading(13))
|
|
56
|
+
.foregroundColor(Palette.text)
|
|
57
|
+
|
|
58
|
+
Spacer()
|
|
59
|
+
}
|
|
60
|
+
.padding(.horizontal, 16)
|
|
61
|
+
.padding(.vertical, 8)
|
|
62
|
+
|
|
63
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// MARK: - Settings Body (General + Shortcuts tabs)
|
|
68
|
+
|
|
69
|
+
private var settingsBody: some View {
|
|
70
|
+
VStack(spacing: 0) {
|
|
71
|
+
// Tab bar
|
|
72
|
+
HStack(spacing: 2) {
|
|
73
|
+
settingsTab(label: "General", id: "general")
|
|
74
|
+
settingsTab(label: "Search & OCR", id: "search")
|
|
75
|
+
settingsTab(label: "Shortcuts", id: "shortcuts")
|
|
76
|
+
Spacer()
|
|
77
|
+
}
|
|
78
|
+
.padding(.horizontal, 14)
|
|
79
|
+
.padding(.vertical, 6)
|
|
80
|
+
|
|
81
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
82
|
+
|
|
83
|
+
// Tab content
|
|
84
|
+
switch selectedTab {
|
|
85
|
+
case "shortcuts": shortcutsContent
|
|
86
|
+
case "search": searchOcrContent
|
|
87
|
+
default: generalContent
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private func settingsTab(label: String, id: String) -> some View {
|
|
93
|
+
let active = selectedTab == id
|
|
94
|
+
return Button {
|
|
95
|
+
selectedTab = id
|
|
96
|
+
} label: {
|
|
97
|
+
Text(label)
|
|
98
|
+
.font(Typo.mono(11))
|
|
99
|
+
.foregroundColor(active ? Palette.text : Palette.textMuted)
|
|
100
|
+
.padding(.horizontal, 10)
|
|
101
|
+
.padding(.vertical, 5)
|
|
102
|
+
.background(
|
|
103
|
+
ZStack {
|
|
104
|
+
if active {
|
|
105
|
+
RoundedRectangle(cornerRadius: 6)
|
|
106
|
+
.fill(Color.white.opacity(0.06))
|
|
107
|
+
RoundedRectangle(cornerRadius: 6)
|
|
108
|
+
.strokeBorder(
|
|
109
|
+
LinearGradient(
|
|
110
|
+
colors: [Color.white.opacity(0.12), Color.white.opacity(0.04)],
|
|
111
|
+
startPoint: .top,
|
|
112
|
+
endPoint: .bottom
|
|
113
|
+
),
|
|
114
|
+
lineWidth: 0.5
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
.buttonStyle(.plain)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// MARK: - Sticky section header
|
|
124
|
+
|
|
125
|
+
private func stickyHeader(_ title: String) -> some View {
|
|
126
|
+
VStack(spacing: 0) {
|
|
127
|
+
HStack {
|
|
128
|
+
Text(title.uppercased())
|
|
129
|
+
.font(Typo.pixel(14))
|
|
130
|
+
.foregroundColor(Palette.textDim)
|
|
131
|
+
.tracking(1)
|
|
132
|
+
Spacer()
|
|
133
|
+
}
|
|
134
|
+
.padding(.horizontal, 20)
|
|
135
|
+
.padding(.vertical, 8)
|
|
136
|
+
.background(Palette.bg)
|
|
137
|
+
|
|
138
|
+
Rectangle()
|
|
139
|
+
.fill(Palette.border)
|
|
140
|
+
.frame(height: 0.5)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// MARK: - General
|
|
145
|
+
|
|
146
|
+
private var generalContent: some View {
|
|
147
|
+
ScrollView {
|
|
148
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
149
|
+
// ── Terminal ──
|
|
150
|
+
settingsCard {
|
|
151
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
152
|
+
Text("Terminal")
|
|
153
|
+
.font(Typo.mono(11))
|
|
154
|
+
.foregroundColor(Palette.text)
|
|
155
|
+
|
|
156
|
+
Picker("", selection: $prefs.terminal) {
|
|
157
|
+
ForEach(Terminal.installed) { t in
|
|
158
|
+
Text(t.rawValue).tag(t)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
.pickerStyle(.segmented)
|
|
162
|
+
.labelsHidden()
|
|
163
|
+
|
|
164
|
+
Text("Used for attaching to tmux sessions")
|
|
165
|
+
.font(Typo.caption(10))
|
|
166
|
+
.foregroundColor(Palette.textMuted)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── tmux ──
|
|
171
|
+
settingsCard {
|
|
172
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
173
|
+
Text("tmux")
|
|
174
|
+
.font(Typo.mono(11))
|
|
175
|
+
.foregroundColor(Palette.text)
|
|
176
|
+
|
|
177
|
+
// Mode
|
|
178
|
+
HStack {
|
|
179
|
+
Text("Detach mode")
|
|
180
|
+
.font(Typo.mono(10))
|
|
181
|
+
.foregroundColor(Palette.textDim)
|
|
182
|
+
Spacer()
|
|
183
|
+
Picker("", selection: $prefs.mode) {
|
|
184
|
+
Text("Learning").tag(InteractionMode.learning)
|
|
185
|
+
Text("Auto").tag(InteractionMode.auto)
|
|
186
|
+
}
|
|
187
|
+
.pickerStyle(.segmented)
|
|
188
|
+
.labelsHidden()
|
|
189
|
+
.frame(width: 160)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Text(prefs.mode == .learning
|
|
193
|
+
? "Shows keybinding hints on detach"
|
|
194
|
+
: "Detaches sessions silently")
|
|
195
|
+
.font(Typo.caption(9))
|
|
196
|
+
.foregroundColor(Palette.textMuted.opacity(0.7))
|
|
197
|
+
|
|
198
|
+
cardDivider
|
|
199
|
+
|
|
200
|
+
// Project scan root
|
|
201
|
+
Text("Project scan root")
|
|
202
|
+
.font(Typo.mono(10))
|
|
203
|
+
.foregroundColor(Palette.textDim)
|
|
204
|
+
|
|
205
|
+
HStack(spacing: 6) {
|
|
206
|
+
TextField("~/dev", text: $prefs.scanRoot)
|
|
207
|
+
.textFieldStyle(.plain)
|
|
208
|
+
.font(Typo.mono(11))
|
|
209
|
+
.foregroundColor(Palette.text)
|
|
210
|
+
.padding(.horizontal, 8)
|
|
211
|
+
.padding(.vertical, 5)
|
|
212
|
+
.background(
|
|
213
|
+
RoundedRectangle(cornerRadius: 5)
|
|
214
|
+
.fill(Color.white.opacity(0.06))
|
|
215
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
Button {
|
|
219
|
+
let panel = NSOpenPanel()
|
|
220
|
+
panel.canChooseDirectories = true
|
|
221
|
+
panel.canChooseFiles = false
|
|
222
|
+
panel.allowsMultipleSelection = false
|
|
223
|
+
if !prefs.scanRoot.isEmpty {
|
|
224
|
+
panel.directoryURL = URL(fileURLWithPath: prefs.scanRoot)
|
|
225
|
+
}
|
|
226
|
+
if panel.runModal() == .OK, let url = panel.url {
|
|
227
|
+
prefs.scanRoot = url.path
|
|
228
|
+
}
|
|
229
|
+
} label: {
|
|
230
|
+
Image(systemName: "folder")
|
|
231
|
+
.font(.system(size: 11))
|
|
232
|
+
.foregroundColor(Palette.textDim)
|
|
233
|
+
.padding(6)
|
|
234
|
+
.background(
|
|
235
|
+
RoundedRectangle(cornerRadius: 5)
|
|
236
|
+
.fill(Color.white.opacity(0.06))
|
|
237
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
.buttonStyle(.plain)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
HStack {
|
|
244
|
+
Text("Scans for .lattices.json project configs")
|
|
245
|
+
.font(Typo.caption(9))
|
|
246
|
+
.foregroundColor(Palette.textMuted.opacity(0.7))
|
|
247
|
+
Spacer()
|
|
248
|
+
Button {
|
|
249
|
+
scanner.updateRoot(prefs.scanRoot)
|
|
250
|
+
scanner.scan()
|
|
251
|
+
} label: {
|
|
252
|
+
Text("Rescan")
|
|
253
|
+
.font(Typo.monoBold(10))
|
|
254
|
+
.foregroundColor(Palette.text)
|
|
255
|
+
.padding(.horizontal, 12)
|
|
256
|
+
.padding(.vertical, 4)
|
|
257
|
+
.background(
|
|
258
|
+
RoundedRectangle(cornerRadius: 4)
|
|
259
|
+
.fill(Palette.surfaceHov)
|
|
260
|
+
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
.buttonStyle(.plain)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
.padding(16)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// MARK: - Search & OCR
|
|
273
|
+
|
|
274
|
+
private func ocrNumField(_ value: Binding<Double>, width: CGFloat = 50) -> some View {
|
|
275
|
+
TextField("", value: value, formatter: NumberFormatter())
|
|
276
|
+
.textFieldStyle(.plain)
|
|
277
|
+
.font(Typo.monoBold(11))
|
|
278
|
+
.foregroundColor(Palette.text)
|
|
279
|
+
.multilineTextAlignment(.center)
|
|
280
|
+
.frame(width: width)
|
|
281
|
+
.padding(.horizontal, 4)
|
|
282
|
+
.padding(.vertical, 3)
|
|
283
|
+
.background(
|
|
284
|
+
RoundedRectangle(cornerRadius: 5)
|
|
285
|
+
.fill(Color.white.opacity(0.06))
|
|
286
|
+
.overlay(
|
|
287
|
+
RoundedRectangle(cornerRadius: 5)
|
|
288
|
+
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private func ocrIntField(_ value: Binding<Int>, width: CGFloat = 36) -> some View {
|
|
294
|
+
TextField("", value: value, formatter: NumberFormatter())
|
|
295
|
+
.textFieldStyle(.plain)
|
|
296
|
+
.font(Typo.monoBold(11))
|
|
297
|
+
.foregroundColor(Palette.text)
|
|
298
|
+
.multilineTextAlignment(.center)
|
|
299
|
+
.frame(width: width)
|
|
300
|
+
.padding(.horizontal, 4)
|
|
301
|
+
.padding(.vertical, 3)
|
|
302
|
+
.background(
|
|
303
|
+
RoundedRectangle(cornerRadius: 5)
|
|
304
|
+
.fill(Color.white.opacity(0.06))
|
|
305
|
+
.overlay(
|
|
306
|
+
RoundedRectangle(cornerRadius: 5)
|
|
307
|
+
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private func ocrSectionLabel(_ text: String) -> some View {
|
|
313
|
+
Text(text)
|
|
314
|
+
.font(Typo.monoBold(10))
|
|
315
|
+
.foregroundColor(Palette.textDim)
|
|
316
|
+
.tracking(0.5)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private var searchOcrContent: some View {
|
|
320
|
+
ScrollView {
|
|
321
|
+
VStack(spacing: 12) {
|
|
322
|
+
// ── Screen Text Recognition Card ──
|
|
323
|
+
settingsCard {
|
|
324
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
325
|
+
// Header row: label + toggle
|
|
326
|
+
HStack {
|
|
327
|
+
HStack(spacing: 8) {
|
|
328
|
+
RoundedRectangle(cornerRadius: 4)
|
|
329
|
+
.fill(prefs.ocrEnabled ? Palette.running.opacity(0.15) : Palette.surface)
|
|
330
|
+
.overlay(
|
|
331
|
+
Image(systemName: "text.viewfinder")
|
|
332
|
+
.font(.system(size: 11, weight: .medium))
|
|
333
|
+
.foregroundColor(prefs.ocrEnabled ? Palette.running : Palette.textMuted)
|
|
334
|
+
)
|
|
335
|
+
.frame(width: 24, height: 24)
|
|
336
|
+
|
|
337
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
338
|
+
Text("Screen text recognition")
|
|
339
|
+
.font(Typo.mono(12))
|
|
340
|
+
.foregroundColor(Palette.text)
|
|
341
|
+
Text("Vision OCR on visible windows")
|
|
342
|
+
.font(Typo.caption(10))
|
|
343
|
+
.foregroundColor(Palette.textMuted)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
Spacer()
|
|
347
|
+
Toggle("", isOn: Binding(
|
|
348
|
+
get: { prefs.ocrEnabled },
|
|
349
|
+
set: { OcrModel.shared.setEnabled($0) }
|
|
350
|
+
))
|
|
351
|
+
.toggleStyle(.switch)
|
|
352
|
+
.controlSize(.small)
|
|
353
|
+
.labelsHidden()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Accuracy
|
|
357
|
+
HStack(spacing: 8) {
|
|
358
|
+
Text("Accuracy")
|
|
359
|
+
.font(Typo.mono(10))
|
|
360
|
+
.foregroundColor(Palette.textDim)
|
|
361
|
+
Picker("", selection: $prefs.ocrAccuracy) {
|
|
362
|
+
Text("Accurate").tag("accurate")
|
|
363
|
+
Text("Fast").tag("fast")
|
|
364
|
+
}
|
|
365
|
+
.pickerStyle(.segmented)
|
|
366
|
+
.labelsHidden()
|
|
367
|
+
.frame(width: 140)
|
|
368
|
+
Spacer()
|
|
369
|
+
}
|
|
370
|
+
.padding(.leading, 32)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── Scan Schedule Card ──
|
|
375
|
+
settingsCard {
|
|
376
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
377
|
+
ocrSectionLabel("Schedule")
|
|
378
|
+
|
|
379
|
+
// Quick scan sentence
|
|
380
|
+
HStack(spacing: 0) {
|
|
381
|
+
Text("Quick scan top ")
|
|
382
|
+
.font(Typo.mono(11))
|
|
383
|
+
.foregroundColor(Palette.textDim)
|
|
384
|
+
ocrIntField($prefs.ocrQuickLimit, width: 32)
|
|
385
|
+
Text(" windows every ")
|
|
386
|
+
.font(Typo.mono(11))
|
|
387
|
+
.foregroundColor(Palette.textDim)
|
|
388
|
+
ocrNumField($prefs.ocrQuickInterval, width: 42)
|
|
389
|
+
Text("s")
|
|
390
|
+
.font(Typo.mono(11))
|
|
391
|
+
.foregroundColor(Palette.textDim)
|
|
392
|
+
Spacer()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
cardDivider
|
|
396
|
+
|
|
397
|
+
// Deep scan sentence
|
|
398
|
+
HStack(spacing: 0) {
|
|
399
|
+
Text("Deep scan up to ")
|
|
400
|
+
.font(Typo.mono(11))
|
|
401
|
+
.foregroundColor(Palette.textDim)
|
|
402
|
+
ocrIntField($prefs.ocrDeepLimit, width: 32)
|
|
403
|
+
Text(" windows every ")
|
|
404
|
+
.font(Typo.mono(11))
|
|
405
|
+
.foregroundColor(Palette.textDim)
|
|
406
|
+
ocrNumField($prefs.ocrDeepInterval, width: 52)
|
|
407
|
+
Text("s")
|
|
408
|
+
.font(Typo.mono(11))
|
|
409
|
+
.foregroundColor(Palette.textDim)
|
|
410
|
+
Spacer()
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
HStack(spacing: 0) {
|
|
414
|
+
Text("OCR budget: ")
|
|
415
|
+
.font(Typo.mono(11))
|
|
416
|
+
.foregroundColor(Palette.textDim)
|
|
417
|
+
ocrIntField($prefs.ocrDeepBudget, width: 32)
|
|
418
|
+
Text(" windows per scan")
|
|
419
|
+
.font(Typo.mono(11))
|
|
420
|
+
.foregroundColor(Palette.textDim)
|
|
421
|
+
Spacer()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Human-readable deep interval
|
|
425
|
+
let h = Int(prefs.ocrDeepInterval / 3600)
|
|
426
|
+
let m = Int(prefs.ocrDeepInterval.truncatingRemainder(dividingBy: 3600) / 60)
|
|
427
|
+
if h > 0 || m > 0 {
|
|
428
|
+
Text("≈ \(h > 0 ? "\(h)h" : "")\(m > 0 ? " \(m)m" : "")")
|
|
429
|
+
.font(Typo.caption(9))
|
|
430
|
+
.foregroundColor(Palette.textMuted.opacity(0.6))
|
|
431
|
+
.padding(.leading, 2)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── Status Card ──
|
|
437
|
+
settingsCard {
|
|
438
|
+
HStack(spacing: 8) {
|
|
439
|
+
let ocrResults = OcrModel.shared.results
|
|
440
|
+
let isScanning = OcrModel.shared.isScanning
|
|
441
|
+
|
|
442
|
+
Circle()
|
|
443
|
+
.fill(isScanning ? Palette.detach : (prefs.ocrEnabled ? Palette.running : Palette.textMuted))
|
|
444
|
+
.frame(width: 6, height: 6)
|
|
445
|
+
|
|
446
|
+
Text(isScanning ? "Scanning..." : (prefs.ocrEnabled ? "\(ocrResults.count) windows cached" : "Disabled"))
|
|
447
|
+
.font(Typo.mono(10))
|
|
448
|
+
.foregroundColor(Palette.textMuted)
|
|
449
|
+
|
|
450
|
+
Spacer()
|
|
451
|
+
|
|
452
|
+
Button {
|
|
453
|
+
OcrModel.shared.scan()
|
|
454
|
+
} label: {
|
|
455
|
+
HStack(spacing: 4) {
|
|
456
|
+
Image(systemName: "arrow.clockwise")
|
|
457
|
+
.font(.system(size: 9, weight: .semibold))
|
|
458
|
+
Text("Scan Now")
|
|
459
|
+
.font(Typo.monoBold(10))
|
|
460
|
+
}
|
|
461
|
+
.foregroundColor(prefs.ocrEnabled ? Palette.text : Palette.textMuted)
|
|
462
|
+
.padding(.horizontal, 10)
|
|
463
|
+
.padding(.vertical, 4)
|
|
464
|
+
.background(
|
|
465
|
+
RoundedRectangle(cornerRadius: 4)
|
|
466
|
+
.fill(prefs.ocrEnabled ? Palette.surfaceHov : Palette.surface)
|
|
467
|
+
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
.buttonStyle(.plain)
|
|
471
|
+
.disabled(!prefs.ocrEnabled)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── Recent Captures ──
|
|
476
|
+
recentCapturesSection
|
|
477
|
+
}
|
|
478
|
+
.padding(16)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// MARK: - Recent Captures Browser
|
|
483
|
+
|
|
484
|
+
private var recentCapturesSection: some View {
|
|
485
|
+
let ocrResults = OcrModel.shared.results
|
|
486
|
+
let grouped = Dictionary(grouping: ocrResults.values, by: \.app)
|
|
487
|
+
.sorted { $0.value.count > $1.value.count }
|
|
488
|
+
|
|
489
|
+
return Group {
|
|
490
|
+
if !grouped.isEmpty {
|
|
491
|
+
settingsCard {
|
|
492
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
493
|
+
ocrSectionLabel("Recent Captures")
|
|
494
|
+
|
|
495
|
+
ForEach(grouped, id: \.key) { app, windows in
|
|
496
|
+
ocrAppGroup(app: app, windows: windows.sorted { $0.timestamp > $1.timestamp })
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private func ocrAppGroup(app: String, windows: [OcrWindowResult]) -> some View {
|
|
505
|
+
let isCollapsed = collapsedOcrApps.contains(app)
|
|
506
|
+
|
|
507
|
+
return VStack(alignment: .leading, spacing: 0) {
|
|
508
|
+
// App header
|
|
509
|
+
Button {
|
|
510
|
+
withAnimation(.easeInOut(duration: 0.15)) {
|
|
511
|
+
if isCollapsed {
|
|
512
|
+
collapsedOcrApps.remove(app)
|
|
513
|
+
} else {
|
|
514
|
+
collapsedOcrApps.insert(app)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} label: {
|
|
518
|
+
HStack(spacing: 6) {
|
|
519
|
+
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
|
|
520
|
+
.font(.system(size: 8, weight: .semibold))
|
|
521
|
+
.foregroundColor(Palette.textMuted)
|
|
522
|
+
.frame(width: 10)
|
|
523
|
+
|
|
524
|
+
Text(app)
|
|
525
|
+
.font(Typo.monoBold(11))
|
|
526
|
+
.foregroundColor(Palette.text)
|
|
527
|
+
|
|
528
|
+
Text("(\(windows.count))")
|
|
529
|
+
.font(Typo.mono(10))
|
|
530
|
+
.foregroundColor(Palette.textMuted)
|
|
531
|
+
|
|
532
|
+
Spacer()
|
|
533
|
+
}
|
|
534
|
+
.padding(.vertical, 4)
|
|
535
|
+
.contentShape(Rectangle())
|
|
536
|
+
}
|
|
537
|
+
.buttonStyle(.plain)
|
|
538
|
+
|
|
539
|
+
if !isCollapsed {
|
|
540
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
541
|
+
ForEach(windows, id: \.wid) { win in
|
|
542
|
+
ocrWindowRow(win)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
.padding(.leading, 16)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private func ocrWindowRow(_ win: OcrWindowResult) -> some View {
|
|
551
|
+
let isExpanded = expandedOcrWindow == win.wid
|
|
552
|
+
let preview = String(win.fullText.prefix(80)).replacingOccurrences(of: "\n", with: " ")
|
|
553
|
+
|
|
554
|
+
return VStack(alignment: .leading, spacing: 0) {
|
|
555
|
+
Button {
|
|
556
|
+
withAnimation(.easeInOut(duration: 0.15)) {
|
|
557
|
+
expandedOcrWindow = isExpanded ? nil : win.wid
|
|
558
|
+
}
|
|
559
|
+
} label: {
|
|
560
|
+
HStack(spacing: 6) {
|
|
561
|
+
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
|
562
|
+
.font(.system(size: 7, weight: .semibold))
|
|
563
|
+
.foregroundColor(Palette.textMuted)
|
|
564
|
+
.frame(width: 8)
|
|
565
|
+
|
|
566
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
567
|
+
HStack(spacing: 6) {
|
|
568
|
+
Text(win.title.isEmpty ? "Untitled" : win.title)
|
|
569
|
+
.font(Typo.mono(10))
|
|
570
|
+
.foregroundColor(Palette.text)
|
|
571
|
+
.lineLimit(1)
|
|
572
|
+
|
|
573
|
+
Spacer()
|
|
574
|
+
|
|
575
|
+
Text(ocrRelativeTime(win.timestamp))
|
|
576
|
+
.font(Typo.caption(9))
|
|
577
|
+
.foregroundColor(Palette.textMuted)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if !isExpanded && !preview.isEmpty {
|
|
581
|
+
Text(preview)
|
|
582
|
+
.font(Typo.caption(9))
|
|
583
|
+
.foregroundColor(Palette.textMuted.opacity(0.7))
|
|
584
|
+
.lineLimit(1)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
.padding(.vertical, 4)
|
|
589
|
+
.contentShape(Rectangle())
|
|
590
|
+
}
|
|
591
|
+
.buttonStyle(.plain)
|
|
592
|
+
|
|
593
|
+
if isExpanded {
|
|
594
|
+
ocrExpandedDetail(win)
|
|
595
|
+
.padding(.leading, 14)
|
|
596
|
+
.padding(.vertical, 4)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private func ocrExpandedDetail(_ win: OcrWindowResult) -> some View {
|
|
602
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
603
|
+
// Metadata row
|
|
604
|
+
HStack(spacing: 10) {
|
|
605
|
+
let avgConfidence = win.texts.isEmpty ? 0 : win.texts.map(\.confidence).reduce(0, +) / Float(win.texts.count)
|
|
606
|
+
Text("\(win.texts.count) blocks")
|
|
607
|
+
.font(Typo.caption(9))
|
|
608
|
+
.foregroundColor(Palette.textMuted)
|
|
609
|
+
Text("confidence: \(String(format: "%.0f%%", avgConfidence * 100))")
|
|
610
|
+
.font(Typo.caption(9))
|
|
611
|
+
.foregroundColor(Palette.textMuted)
|
|
612
|
+
Spacer()
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Full text in scrollable monospaced area
|
|
616
|
+
ScrollView {
|
|
617
|
+
Text(win.fullText)
|
|
618
|
+
.font(.system(size: 10, design: .monospaced))
|
|
619
|
+
.foregroundColor(Palette.textDim)
|
|
620
|
+
.textSelection(.enabled)
|
|
621
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
622
|
+
.padding(8)
|
|
623
|
+
}
|
|
624
|
+
.frame(maxHeight: 150)
|
|
625
|
+
.background(
|
|
626
|
+
RoundedRectangle(cornerRadius: 4)
|
|
627
|
+
.fill(Color.black.opacity(0.2))
|
|
628
|
+
.overlay(
|
|
629
|
+
RoundedRectangle(cornerRadius: 4)
|
|
630
|
+
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private func ocrRelativeTime(_ date: Date) -> String {
|
|
637
|
+
let seconds = Int(-date.timeIntervalSinceNow)
|
|
638
|
+
if seconds < 60 { return "just now" }
|
|
639
|
+
let minutes = seconds / 60
|
|
640
|
+
if minutes < 60 { return "\(minutes)m ago" }
|
|
641
|
+
let hours = minutes / 60
|
|
642
|
+
if hours < 24 { return "\(hours)h ago" }
|
|
643
|
+
return "\(hours / 24)d ago"
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// MARK: - Settings Card
|
|
647
|
+
|
|
648
|
+
private func settingsCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
649
|
+
content()
|
|
650
|
+
.padding(.horizontal, 14)
|
|
651
|
+
.padding(.vertical, 12)
|
|
652
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
653
|
+
.liquidGlass()
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private var cardDivider: some View {
|
|
657
|
+
Rectangle()
|
|
658
|
+
.fill(
|
|
659
|
+
LinearGradient(
|
|
660
|
+
colors: [Color.white.opacity(0.03), Color.white.opacity(0.08), Color.white.opacity(0.03)],
|
|
661
|
+
startPoint: .leading,
|
|
662
|
+
endPoint: .trailing
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
.frame(height: 0.5)
|
|
666
|
+
.padding(.vertical, 3)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// MARK: - Shortcuts (Spatial Layout)
|
|
670
|
+
|
|
671
|
+
private var shortcutsContent: some View {
|
|
672
|
+
VStack(spacing: 0) {
|
|
673
|
+
GeometryReader { geo in
|
|
674
|
+
let spacing: CGFloat = 16
|
|
675
|
+
let pad: CGFloat = 20
|
|
676
|
+
let total = geo.size.width - pad * 2 - spacing * 2
|
|
677
|
+
let leftW = total * 0.35
|
|
678
|
+
let centerW = total * 0.35
|
|
679
|
+
let rightW = total * 0.30
|
|
680
|
+
|
|
681
|
+
ScrollView {
|
|
682
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
683
|
+
HStack(alignment: .top, spacing: spacing) {
|
|
684
|
+
shortcutsLeftColumn
|
|
685
|
+
.frame(width: leftW, alignment: .leading)
|
|
686
|
+
.clipped()
|
|
687
|
+
shortcutsCenterColumn
|
|
688
|
+
.frame(width: centerW, alignment: .leading)
|
|
689
|
+
.clipped()
|
|
690
|
+
shortcutsRightColumn
|
|
691
|
+
.frame(width: rightW, alignment: .leading)
|
|
692
|
+
.clipped()
|
|
693
|
+
}
|
|
694
|
+
.padding(.horizontal, pad)
|
|
695
|
+
.padding(.vertical, 16)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
Spacer(minLength: 0)
|
|
701
|
+
|
|
702
|
+
separator
|
|
703
|
+
|
|
704
|
+
HStack {
|
|
705
|
+
Spacer()
|
|
706
|
+
Button {
|
|
707
|
+
hotkeyStore.resetAll()
|
|
708
|
+
} label: {
|
|
709
|
+
Text("Reset All to Defaults")
|
|
710
|
+
.font(Typo.caption(11))
|
|
711
|
+
.foregroundColor(Palette.textDim)
|
|
712
|
+
.padding(.horizontal, 12)
|
|
713
|
+
.padding(.vertical, 5)
|
|
714
|
+
.background(
|
|
715
|
+
RoundedRectangle(cornerRadius: 3)
|
|
716
|
+
.fill(Palette.surface)
|
|
717
|
+
.overlay(
|
|
718
|
+
RoundedRectangle(cornerRadius: 3)
|
|
719
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
720
|
+
)
|
|
721
|
+
)
|
|
722
|
+
}
|
|
723
|
+
.buttonStyle(.plain)
|
|
724
|
+
}
|
|
725
|
+
.padding(.horizontal, 20)
|
|
726
|
+
.padding(.vertical, 10)
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// MARK: - Shortcuts: Left Column (App + Layers)
|
|
731
|
+
|
|
732
|
+
private var shortcutsLeftColumn: some View {
|
|
733
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
734
|
+
columnHeader("App & Layers")
|
|
735
|
+
|
|
736
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
737
|
+
ForEach(HotkeyAction.allCases.filter { $0.group == .app }, id: \.rawValue) { action in
|
|
738
|
+
compactKeyRecorder(action: action)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
743
|
+
|
|
744
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
745
|
+
ForEach(HotkeyAction.layerActions, id: \.rawValue) { action in
|
|
746
|
+
compactKeyRecorder(action: action)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// MARK: - Shortcuts: Center Column (Tiling)
|
|
753
|
+
|
|
754
|
+
private var shortcutsCenterColumn: some View {
|
|
755
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
756
|
+
columnHeader("Tiling")
|
|
757
|
+
|
|
758
|
+
// Monitor visualization — 3x3 grid
|
|
759
|
+
VStack(spacing: 2) {
|
|
760
|
+
HStack(spacing: 2) {
|
|
761
|
+
tileCell(action: .tileTopLeft, label: "TL")
|
|
762
|
+
tileCell(action: .tileTop, label: "Top")
|
|
763
|
+
tileCell(action: .tileTopRight, label: "TR")
|
|
764
|
+
}
|
|
765
|
+
HStack(spacing: 2) {
|
|
766
|
+
tileCell(action: .tileLeft, label: "Left")
|
|
767
|
+
tileCell(action: .tileMaximize, label: "Max")
|
|
768
|
+
tileCell(action: .tileRight, label: "Right")
|
|
769
|
+
}
|
|
770
|
+
HStack(spacing: 2) {
|
|
771
|
+
tileCell(action: .tileBottomLeft, label: "BL")
|
|
772
|
+
tileCell(action: .tileBottom, label: "Bottom")
|
|
773
|
+
tileCell(action: .tileBottomRight, label: "BR")
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
.padding(6)
|
|
777
|
+
.background(
|
|
778
|
+
RoundedRectangle(cornerRadius: 6)
|
|
779
|
+
.fill(Color.black.opacity(0.25))
|
|
780
|
+
.overlay(
|
|
781
|
+
RoundedRectangle(cornerRadius: 6)
|
|
782
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
783
|
+
)
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
// Thirds row
|
|
787
|
+
HStack(spacing: 2) {
|
|
788
|
+
tileCell(action: .tileLeftThird, label: "\u{2153}L")
|
|
789
|
+
tileCell(action: .tileCenterThird, label: "\u{2153}C")
|
|
790
|
+
tileCell(action: .tileRightThird, label: "\u{2153}R")
|
|
791
|
+
}
|
|
792
|
+
.padding(6)
|
|
793
|
+
.background(
|
|
794
|
+
RoundedRectangle(cornerRadius: 6)
|
|
795
|
+
.fill(Color.black.opacity(0.25))
|
|
796
|
+
.overlay(
|
|
797
|
+
RoundedRectangle(cornerRadius: 6)
|
|
798
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
799
|
+
)
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
// Center + Distribute
|
|
803
|
+
HStack(spacing: 4) {
|
|
804
|
+
compactKeyRecorder(action: .tileCenter)
|
|
805
|
+
compactKeyRecorder(action: .tileDistribute)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// MARK: - Shortcuts: Right Column (tmux)
|
|
811
|
+
|
|
812
|
+
private var shortcutsRightColumn: some View {
|
|
813
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
814
|
+
columnHeader("Inside tmux")
|
|
815
|
+
|
|
816
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
817
|
+
shortcutRow("Detach", keys: ["Ctrl+B", "D"])
|
|
818
|
+
shortcutRow("Kill pane", keys: ["Ctrl+B", "X"])
|
|
819
|
+
shortcutRow("Pane left", keys: ["Ctrl+B", "\u{2190}"])
|
|
820
|
+
shortcutRow("Pane right", keys: ["Ctrl+B", "\u{2192}"])
|
|
821
|
+
shortcutRow("Zoom toggle", keys: ["Ctrl+B", "Z"])
|
|
822
|
+
shortcutRow("Scroll mode", keys: ["Ctrl+B", "["])
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// MARK: - Column header
|
|
828
|
+
|
|
829
|
+
private func columnHeader(_ title: String) -> some View {
|
|
830
|
+
Text(title.uppercased())
|
|
831
|
+
.font(Typo.pixel(12))
|
|
832
|
+
.foregroundColor(Palette.textDim)
|
|
833
|
+
.tracking(1)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// MARK: - Tile cell (spatial grid item)
|
|
837
|
+
|
|
838
|
+
private func tileCell(action: HotkeyAction, label: String) -> some View {
|
|
839
|
+
let binding = hotkeyStore.bindings[action]
|
|
840
|
+
let badgeText = binding?.displayParts.last ?? ""
|
|
841
|
+
|
|
842
|
+
return Button {
|
|
843
|
+
// Open inline key recorder for this action
|
|
844
|
+
} label: {
|
|
845
|
+
VStack(spacing: 3) {
|
|
846
|
+
Text(label)
|
|
847
|
+
.font(Typo.caption(9))
|
|
848
|
+
.foregroundColor(Palette.textDim)
|
|
849
|
+
Text(badgeText)
|
|
850
|
+
.font(Typo.geistMonoBold(9))
|
|
851
|
+
.foregroundColor(Palette.text)
|
|
852
|
+
}
|
|
853
|
+
.frame(maxWidth: .infinity)
|
|
854
|
+
.frame(height: 42)
|
|
855
|
+
.background(
|
|
856
|
+
RoundedRectangle(cornerRadius: 4)
|
|
857
|
+
.fill(Palette.surface)
|
|
858
|
+
.overlay(
|
|
859
|
+
RoundedRectangle(cornerRadius: 4)
|
|
860
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
861
|
+
)
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
.buttonStyle(.plain)
|
|
865
|
+
.popover(isPresented: tileCellPopoverBinding(for: action)) {
|
|
866
|
+
KeyRecorderView(action: action, store: hotkeyStore)
|
|
867
|
+
.padding(12)
|
|
868
|
+
.frame(width: 300)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
@State private var expandedOcrWindow: UInt32?
|
|
873
|
+
@State private var collapsedOcrApps: Set<String> = []
|
|
874
|
+
|
|
875
|
+
@State private var activeTilePopover: HotkeyAction?
|
|
876
|
+
|
|
877
|
+
private func tileCellPopoverBinding(for action: HotkeyAction) -> Binding<Bool> {
|
|
878
|
+
Binding(
|
|
879
|
+
get: { activeTilePopover == action },
|
|
880
|
+
set: { if !$0 { activeTilePopover = nil } }
|
|
881
|
+
)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// MARK: - Compact key recorder
|
|
885
|
+
|
|
886
|
+
private func compactKeyRecorder(action: HotkeyAction) -> some View {
|
|
887
|
+
KeyRecorderView(action: action, store: hotkeyStore)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// MARK: - Shortcut row (read-only, for tmux)
|
|
891
|
+
|
|
892
|
+
private func shortcutRow(_ label: String, keys: [String]) -> some View {
|
|
893
|
+
HStack {
|
|
894
|
+
Text(label)
|
|
895
|
+
.font(Typo.caption(11))
|
|
896
|
+
.foregroundColor(Palette.textDim)
|
|
897
|
+
.frame(width: 80, alignment: .trailing)
|
|
898
|
+
|
|
899
|
+
HStack(spacing: 4) {
|
|
900
|
+
ForEach(keys, id: \.self) { key in
|
|
901
|
+
keyBadge(key)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
.padding(.leading, 8)
|
|
905
|
+
|
|
906
|
+
Spacer()
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// MARK: - Docs
|
|
911
|
+
|
|
912
|
+
private var docsContent: some View {
|
|
913
|
+
ScrollView {
|
|
914
|
+
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) {
|
|
915
|
+
Section(header: stickyHeader("What is lattices?")) {
|
|
916
|
+
Text("A developer workspace launcher. It creates pre-configured terminal layouts for your projects using tmux \u{2014} go from \u{201C}I want to work on X\u{201D} to a full environment in one click.")
|
|
917
|
+
.font(Typo.caption(11))
|
|
918
|
+
.foregroundColor(Palette.textDim)
|
|
919
|
+
.lineSpacing(3)
|
|
920
|
+
.padding(.horizontal, 20)
|
|
921
|
+
.padding(.vertical, 12)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
Section(header: stickyHeader("Glossary")) {
|
|
925
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
926
|
+
glossaryItem("Session",
|
|
927
|
+
"A persistent workspace that lives in the background. Survives terminal crashes, disconnects, even closing your laptop.")
|
|
928
|
+
glossaryItem("Pane",
|
|
929
|
+
"A single terminal view inside a session. A typical setup has two panes \u{2014} Claude Code on the left, dev server on the right.")
|
|
930
|
+
glossaryItem("Attach",
|
|
931
|
+
"Connect your terminal window to an existing session. The session was already running \u{2014} you\u{2019}re just viewing it.")
|
|
932
|
+
glossaryItem("Detach",
|
|
933
|
+
"Disconnect your terminal but keep the session alive. Your dev server keeps running, Claude keeps thinking.")
|
|
934
|
+
glossaryItem("tmux",
|
|
935
|
+
"Terminal multiplexer \u{2014} the engine behind lattices. It manages sessions, panes, and layouts. lattices configures it so you don\u{2019}t have to.")
|
|
936
|
+
}
|
|
937
|
+
.padding(.horizontal, 20)
|
|
938
|
+
.padding(.vertical, 12)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
Section(header: stickyHeader("How it works")) {
|
|
942
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
943
|
+
flowStep("1", "Create a .lattices.json in your project root")
|
|
944
|
+
flowStep("2", "lattices reads the config and builds a tmux session")
|
|
945
|
+
flowStep("3", "Each pane gets its command (claude, dev server, etc.)")
|
|
946
|
+
flowStep("4", "Session persists in the background until you kill it")
|
|
947
|
+
flowStep("5", "Attach and detach from any terminal, any time")
|
|
948
|
+
}
|
|
949
|
+
.padding(.horizontal, 20)
|
|
950
|
+
.padding(.vertical, 12)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
Section(header: stickyHeader("Reference")) {
|
|
954
|
+
HStack(spacing: 8) {
|
|
955
|
+
docsLinkButton(icon: "doc.text", label: "Config format", file: "config.md")
|
|
956
|
+
docsLinkButton(icon: "book", label: "Full concepts", file: "concepts.md")
|
|
957
|
+
}
|
|
958
|
+
.padding(.horizontal, 20)
|
|
959
|
+
.padding(.vertical, 12)
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// MARK: - Docs helpers
|
|
966
|
+
|
|
967
|
+
private func glossaryItem(_ term: String, _ definition: String) -> some View {
|
|
968
|
+
VStack(alignment: .leading, spacing: 3) {
|
|
969
|
+
Text(term)
|
|
970
|
+
.font(Typo.monoBold(11))
|
|
971
|
+
.foregroundColor(Palette.text)
|
|
972
|
+
Text(definition)
|
|
973
|
+
.font(Typo.caption(10.5))
|
|
974
|
+
.foregroundColor(Palette.textMuted)
|
|
975
|
+
.lineSpacing(2)
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private func flowStep(_ number: String, _ text: String) -> some View {
|
|
980
|
+
HStack(alignment: .top, spacing: 8) {
|
|
981
|
+
Text(number)
|
|
982
|
+
.font(Typo.monoBold(10))
|
|
983
|
+
.foregroundColor(Palette.running)
|
|
984
|
+
.frame(width: 14)
|
|
985
|
+
Text(text)
|
|
986
|
+
.font(Typo.caption(11))
|
|
987
|
+
.foregroundColor(Palette.textDim)
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private func docsLinkButton(icon: String, label: String, file: String) -> some View {
|
|
992
|
+
Button {
|
|
993
|
+
let path = resolveDocsFile(file)
|
|
994
|
+
NSWorkspace.shared.open(URL(fileURLWithPath: path))
|
|
995
|
+
} label: {
|
|
996
|
+
HStack(spacing: 6) {
|
|
997
|
+
Image(systemName: icon)
|
|
998
|
+
.font(.system(size: 10))
|
|
999
|
+
Text(label)
|
|
1000
|
+
.font(Typo.caption(11))
|
|
1001
|
+
}
|
|
1002
|
+
.foregroundColor(Palette.textDim)
|
|
1003
|
+
.padding(.horizontal, 12)
|
|
1004
|
+
.padding(.vertical, 6)
|
|
1005
|
+
.background(
|
|
1006
|
+
RoundedRectangle(cornerRadius: 3)
|
|
1007
|
+
.fill(Palette.surface)
|
|
1008
|
+
.overlay(
|
|
1009
|
+
RoundedRectangle(cornerRadius: 3)
|
|
1010
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
1011
|
+
)
|
|
1012
|
+
)
|
|
1013
|
+
}
|
|
1014
|
+
.buttonStyle(.plain)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
private func resolveDocsFile(_ file: String) -> String {
|
|
1018
|
+
let devPath = "/Users/arach/dev/lattice/docs/\(file)"
|
|
1019
|
+
if FileManager.default.fileExists(atPath: devPath) { return devPath }
|
|
1020
|
+
let bundle = Bundle.main.bundlePath
|
|
1021
|
+
let appDir = (bundle as NSString).deletingLastPathComponent
|
|
1022
|
+
let docsPath = ((appDir as NSString).appendingPathComponent("../docs/\(file)") as NSString).standardizingPath
|
|
1023
|
+
if FileManager.default.fileExists(atPath: docsPath) { return docsPath }
|
|
1024
|
+
return devPath
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// MARK: - Shared helpers
|
|
1028
|
+
|
|
1029
|
+
private var separator: some View {
|
|
1030
|
+
Rectangle()
|
|
1031
|
+
.fill(Palette.border)
|
|
1032
|
+
.frame(height: 0.5)
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private func settingsRow<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
|
|
1036
|
+
HStack(alignment: .top, spacing: 0) {
|
|
1037
|
+
Text(label)
|
|
1038
|
+
.font(Typo.caption(11))
|
|
1039
|
+
.foregroundColor(Palette.textDim)
|
|
1040
|
+
.frame(width: 100, alignment: .trailing)
|
|
1041
|
+
.padding(.top, 2)
|
|
1042
|
+
|
|
1043
|
+
content()
|
|
1044
|
+
.padding(.leading, 16)
|
|
1045
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private func keyBadge(_ key: String) -> some View {
|
|
1050
|
+
Text(key)
|
|
1051
|
+
.font(Typo.geistMonoBold(10))
|
|
1052
|
+
.foregroundColor(Palette.text)
|
|
1053
|
+
.padding(.horizontal, 6)
|
|
1054
|
+
.padding(.vertical, 3)
|
|
1055
|
+
.background(
|
|
1056
|
+
RoundedRectangle(cornerRadius: 3)
|
|
1057
|
+
.fill(Palette.surface)
|
|
1058
|
+
.overlay(
|
|
1059
|
+
RoundedRectangle(cornerRadius: 3)
|
|
1060
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
1061
|
+
)
|
|
1062
|
+
)
|
|
1063
|
+
}
|
|
1064
|
+
}
|