@arach/lattices 0.1.0 → 0.2.1
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 +101 -90
- 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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct OmniSearchView: View {
|
|
4
|
+
@ObservedObject var state: OmniSearchState
|
|
5
|
+
var onDismiss: () -> Void
|
|
6
|
+
|
|
7
|
+
@FocusState private var searchFocused: Bool
|
|
8
|
+
|
|
9
|
+
var body: some View {
|
|
10
|
+
VStack(spacing: 0) {
|
|
11
|
+
// Search field
|
|
12
|
+
HStack(spacing: 8) {
|
|
13
|
+
Image(systemName: "magnifyingglass")
|
|
14
|
+
.foregroundColor(Palette.textMuted)
|
|
15
|
+
.font(.system(size: 13))
|
|
16
|
+
|
|
17
|
+
TextField("Search windows, projects, sessions...", text: $state.query)
|
|
18
|
+
.textFieldStyle(.plain)
|
|
19
|
+
.font(Typo.mono(14))
|
|
20
|
+
.foregroundColor(Palette.text)
|
|
21
|
+
.focused($searchFocused)
|
|
22
|
+
|
|
23
|
+
if !state.query.isEmpty {
|
|
24
|
+
Button { state.query = "" } label: {
|
|
25
|
+
Image(systemName: "xmark.circle.fill")
|
|
26
|
+
.foregroundColor(Palette.textMuted)
|
|
27
|
+
.font(.system(size: 12))
|
|
28
|
+
}
|
|
29
|
+
.buttonStyle(.plain)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
.padding(.horizontal, 14)
|
|
33
|
+
.padding(.vertical, 12)
|
|
34
|
+
.background(Palette.surface)
|
|
35
|
+
|
|
36
|
+
Rectangle()
|
|
37
|
+
.fill(Palette.border)
|
|
38
|
+
.frame(height: 0.5)
|
|
39
|
+
|
|
40
|
+
// Content
|
|
41
|
+
if state.query.isEmpty {
|
|
42
|
+
summaryView
|
|
43
|
+
} else if state.results.isEmpty {
|
|
44
|
+
emptyResults
|
|
45
|
+
} else {
|
|
46
|
+
resultsView
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
.frame(minWidth: 520, idealWidth: 520, maxWidth: 700, minHeight: 360, idealHeight: 480, maxHeight: 600)
|
|
50
|
+
.background(PanelBackground())
|
|
51
|
+
.preferredColorScheme(.dark)
|
|
52
|
+
.onAppear {
|
|
53
|
+
searchFocused = true
|
|
54
|
+
state.refreshSummary()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// MARK: - Results
|
|
59
|
+
|
|
60
|
+
private var resultsView: some View {
|
|
61
|
+
ScrollViewReader { proxy in
|
|
62
|
+
ScrollView {
|
|
63
|
+
LazyVStack(alignment: .leading, spacing: 2) {
|
|
64
|
+
var flatIndex = 0
|
|
65
|
+
ForEach(state.groupedResults, id: \.0) { group, items in
|
|
66
|
+
// Group header
|
|
67
|
+
Text(group.uppercased())
|
|
68
|
+
.font(Typo.caption(9))
|
|
69
|
+
.foregroundColor(Palette.textMuted)
|
|
70
|
+
.padding(.horizontal, 14)
|
|
71
|
+
.padding(.top, 8)
|
|
72
|
+
.padding(.bottom, 2)
|
|
73
|
+
|
|
74
|
+
ForEach(items) { item in
|
|
75
|
+
let idx = flatIndex
|
|
76
|
+
let _ = { flatIndex += 1 }()
|
|
77
|
+
resultRow(item, index: idx)
|
|
78
|
+
.id(item.id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
.padding(.vertical, 4)
|
|
83
|
+
}
|
|
84
|
+
.onChange(of: state.selectedIndex) { newVal in
|
|
85
|
+
if newVal < state.results.count {
|
|
86
|
+
let item = state.results[newVal]
|
|
87
|
+
withAnimation(.easeOut(duration: 0.1)) {
|
|
88
|
+
proxy.scrollTo(item.id, anchor: .center)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private func resultRow(_ item: OmniResult, index: Int) -> some View {
|
|
96
|
+
let isSelected = index == state.selectedIndex
|
|
97
|
+
return Button {
|
|
98
|
+
item.action()
|
|
99
|
+
onDismiss()
|
|
100
|
+
} label: {
|
|
101
|
+
HStack(spacing: 10) {
|
|
102
|
+
Image(systemName: item.icon)
|
|
103
|
+
.font(.system(size: 11, weight: .medium))
|
|
104
|
+
.foregroundColor(isSelected ? Palette.text : Palette.textDim)
|
|
105
|
+
.frame(width: 16)
|
|
106
|
+
|
|
107
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
108
|
+
Text(item.title)
|
|
109
|
+
.font(Typo.mono(12))
|
|
110
|
+
.foregroundColor(isSelected ? Palette.text : Palette.textDim)
|
|
111
|
+
.lineLimit(1)
|
|
112
|
+
|
|
113
|
+
Text(item.subtitle)
|
|
114
|
+
.font(Typo.mono(10))
|
|
115
|
+
.foregroundColor(Palette.textMuted)
|
|
116
|
+
.lineLimit(1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
Spacer()
|
|
120
|
+
|
|
121
|
+
Text(item.kind.rawValue)
|
|
122
|
+
.font(Typo.mono(9))
|
|
123
|
+
.foregroundColor(Palette.textMuted)
|
|
124
|
+
.padding(.horizontal, 5)
|
|
125
|
+
.padding(.vertical, 2)
|
|
126
|
+
.background(
|
|
127
|
+
RoundedRectangle(cornerRadius: 3)
|
|
128
|
+
.fill(Palette.surface)
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
.padding(.horizontal, 14)
|
|
132
|
+
.padding(.vertical, 6)
|
|
133
|
+
.background(
|
|
134
|
+
RoundedRectangle(cornerRadius: 5)
|
|
135
|
+
.fill(isSelected ? Palette.surfaceHov : Color.clear)
|
|
136
|
+
)
|
|
137
|
+
.contentShape(Rectangle())
|
|
138
|
+
}
|
|
139
|
+
.buttonStyle(.plain)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// MARK: - Empty Results
|
|
143
|
+
|
|
144
|
+
private var emptyResults: some View {
|
|
145
|
+
VStack(spacing: 12) {
|
|
146
|
+
Spacer()
|
|
147
|
+
Image(systemName: "magnifyingglass")
|
|
148
|
+
.font(.system(size: 24, weight: .light))
|
|
149
|
+
.foregroundColor(Palette.textMuted)
|
|
150
|
+
Text("No results for \"\(state.query)\"")
|
|
151
|
+
.font(Typo.mono(12))
|
|
152
|
+
.foregroundColor(Palette.textDim)
|
|
153
|
+
Spacer()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// MARK: - Activity Summary
|
|
158
|
+
|
|
159
|
+
private var summaryView: some View {
|
|
160
|
+
ScrollView {
|
|
161
|
+
VStack(alignment: .leading, spacing: 14) {
|
|
162
|
+
if let summary = state.activitySummary {
|
|
163
|
+
// Windows by app
|
|
164
|
+
summarySection("WINDOWS", icon: "macwindow", count: summary.totalWindows) {
|
|
165
|
+
ForEach(summary.windowsByApp) { app in
|
|
166
|
+
HStack {
|
|
167
|
+
Text(app.appName)
|
|
168
|
+
.font(Typo.mono(11))
|
|
169
|
+
.foregroundColor(Palette.textDim)
|
|
170
|
+
.lineLimit(1)
|
|
171
|
+
Spacer()
|
|
172
|
+
Text("\(app.count)")
|
|
173
|
+
.font(Typo.monoBold(11))
|
|
174
|
+
.foregroundColor(Palette.text)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Sessions
|
|
180
|
+
if !summary.sessions.isEmpty {
|
|
181
|
+
summarySection("TMUX SESSIONS", icon: "terminal", count: summary.sessions.count) {
|
|
182
|
+
ForEach(summary.sessions) { session in
|
|
183
|
+
HStack {
|
|
184
|
+
Circle()
|
|
185
|
+
.fill(session.attached ? Palette.running : Palette.textMuted)
|
|
186
|
+
.frame(width: 6, height: 6)
|
|
187
|
+
Text(session.name)
|
|
188
|
+
.font(Typo.mono(11))
|
|
189
|
+
.foregroundColor(Palette.textDim)
|
|
190
|
+
Spacer()
|
|
191
|
+
Text("\(session.paneCount) panes")
|
|
192
|
+
.font(Typo.mono(10))
|
|
193
|
+
.foregroundColor(Palette.textMuted)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Processes
|
|
200
|
+
if !summary.interestingProcesses.isEmpty {
|
|
201
|
+
summarySection("PROCESSES", icon: "gearshape", count: summary.interestingProcesses.count) {
|
|
202
|
+
ForEach(Array(summary.interestingProcesses.prefix(10).enumerated()), id: \.offset) { _, proc in
|
|
203
|
+
HStack {
|
|
204
|
+
Text(proc.comm)
|
|
205
|
+
.font(Typo.monoBold(11))
|
|
206
|
+
.foregroundColor(Palette.textDim)
|
|
207
|
+
if let cwd = proc.cwd {
|
|
208
|
+
Text(cwd.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
|
|
209
|
+
.font(Typo.mono(10))
|
|
210
|
+
.foregroundColor(Palette.textMuted)
|
|
211
|
+
.lineLimit(1)
|
|
212
|
+
}
|
|
213
|
+
Spacer()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// OCR info
|
|
220
|
+
if summary.ocrWindowCount > 0 {
|
|
221
|
+
HStack(spacing: 6) {
|
|
222
|
+
Image(systemName: "doc.text.magnifyingglass")
|
|
223
|
+
.font(.system(size: 10))
|
|
224
|
+
.foregroundColor(Palette.textMuted)
|
|
225
|
+
Text("OCR: \(summary.ocrWindowCount) windows scanned")
|
|
226
|
+
.font(Typo.mono(10))
|
|
227
|
+
.foregroundColor(Palette.textMuted)
|
|
228
|
+
if let t = summary.lastOcrScan {
|
|
229
|
+
Spacer()
|
|
230
|
+
Text(relativeTime(t))
|
|
231
|
+
.font(Typo.mono(9))
|
|
232
|
+
.foregroundColor(Palette.textMuted)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
.padding(.horizontal, 14)
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
Text("Loading...")
|
|
239
|
+
.font(Typo.mono(11))
|
|
240
|
+
.foregroundColor(Palette.textMuted)
|
|
241
|
+
.padding(14)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
.padding(.vertical, 10)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private func summarySection<Content: View>(
|
|
249
|
+
_ title: String,
|
|
250
|
+
icon: String,
|
|
251
|
+
count: Int,
|
|
252
|
+
@ViewBuilder content: () -> Content
|
|
253
|
+
) -> some View {
|
|
254
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
255
|
+
HStack(spacing: 6) {
|
|
256
|
+
Image(systemName: icon)
|
|
257
|
+
.font(.system(size: 10, weight: .medium))
|
|
258
|
+
.foregroundColor(Palette.textMuted)
|
|
259
|
+
Text(title)
|
|
260
|
+
.font(Typo.caption(9))
|
|
261
|
+
.foregroundColor(Palette.textMuted)
|
|
262
|
+
Text("\(count)")
|
|
263
|
+
.font(Typo.monoBold(9))
|
|
264
|
+
.foregroundColor(Palette.running)
|
|
265
|
+
.padding(.horizontal, 4)
|
|
266
|
+
.padding(.vertical, 1)
|
|
267
|
+
.background(
|
|
268
|
+
RoundedRectangle(cornerRadius: 3)
|
|
269
|
+
.fill(Palette.running.opacity(0.12))
|
|
270
|
+
)
|
|
271
|
+
Spacer()
|
|
272
|
+
}
|
|
273
|
+
.padding(.horizontal, 14)
|
|
274
|
+
|
|
275
|
+
VStack(spacing: 3) {
|
|
276
|
+
content()
|
|
277
|
+
}
|
|
278
|
+
.padding(.horizontal, 14)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private func relativeTime(_ date: Date) -> String {
|
|
283
|
+
let seconds = Int(Date().timeIntervalSince(date))
|
|
284
|
+
if seconds < 60 { return "\(seconds)s ago" }
|
|
285
|
+
if seconds < 3600 { return "\(seconds / 60)m ago" }
|
|
286
|
+
return "\(seconds / 3600)h ago"
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
final class OmniSearchWindow {
|
|
5
|
+
static let shared = OmniSearchWindow()
|
|
6
|
+
|
|
7
|
+
private var panel: NSPanel?
|
|
8
|
+
private var keyMonitor: Any?
|
|
9
|
+
private var state: OmniSearchState?
|
|
10
|
+
|
|
11
|
+
var isVisible: Bool { panel?.isVisible ?? false }
|
|
12
|
+
|
|
13
|
+
func toggle() {
|
|
14
|
+
if isVisible {
|
|
15
|
+
dismiss()
|
|
16
|
+
} else {
|
|
17
|
+
show()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func show() {
|
|
22
|
+
if let p = panel, p.isVisible {
|
|
23
|
+
p.makeKeyAndOrderFront(nil)
|
|
24
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fresh state each time
|
|
29
|
+
let searchState = OmniSearchState()
|
|
30
|
+
state = searchState
|
|
31
|
+
|
|
32
|
+
let view = OmniSearchView(state: searchState) { [weak self] in
|
|
33
|
+
self?.dismiss()
|
|
34
|
+
}
|
|
35
|
+
.preferredColorScheme(.dark)
|
|
36
|
+
|
|
37
|
+
let hosting = NSHostingController(rootView: view)
|
|
38
|
+
hosting.preferredContentSize = NSSize(width: 520, height: 480)
|
|
39
|
+
|
|
40
|
+
let p = NSPanel(
|
|
41
|
+
contentRect: NSRect(x: 0, y: 0, width: 520, height: 480),
|
|
42
|
+
styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
|
|
43
|
+
backing: .buffered,
|
|
44
|
+
defer: false
|
|
45
|
+
)
|
|
46
|
+
p.contentViewController = hosting
|
|
47
|
+
p.title = "Omni Search"
|
|
48
|
+
p.titlebarAppearsTransparent = true
|
|
49
|
+
p.titleVisibility = .hidden
|
|
50
|
+
p.isMovableByWindowBackground = true
|
|
51
|
+
p.level = .floating
|
|
52
|
+
p.isOpaque = false
|
|
53
|
+
p.backgroundColor = NSColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1.0)
|
|
54
|
+
p.hasShadow = true
|
|
55
|
+
p.appearance = NSAppearance(named: .darkAqua)
|
|
56
|
+
p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
57
|
+
p.minSize = NSSize(width: 400, height: 300)
|
|
58
|
+
p.maxSize = NSSize(width: 700, height: 700)
|
|
59
|
+
|
|
60
|
+
// Center on screen
|
|
61
|
+
if let screen = NSScreen.main {
|
|
62
|
+
let visibleFrame = screen.visibleFrame
|
|
63
|
+
let x = visibleFrame.midX - 260
|
|
64
|
+
let y = visibleFrame.midY + 60 // slightly above center
|
|
65
|
+
p.setFrameOrigin(NSPoint(x: x, y: y))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
p.makeKeyAndOrderFront(nil)
|
|
69
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
70
|
+
panel = p
|
|
71
|
+
|
|
72
|
+
// Key monitor: Escape → dismiss, arrow keys → navigate, Enter → activate
|
|
73
|
+
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
74
|
+
guard self?.panel?.isKeyWindow == true else { return event }
|
|
75
|
+
|
|
76
|
+
switch event.keyCode {
|
|
77
|
+
case 53: // Escape
|
|
78
|
+
self?.dismiss()
|
|
79
|
+
return nil
|
|
80
|
+
case 125: // ↓
|
|
81
|
+
self?.state?.moveSelection(1)
|
|
82
|
+
return nil
|
|
83
|
+
case 126: // ↑
|
|
84
|
+
self?.state?.moveSelection(-1)
|
|
85
|
+
return nil
|
|
86
|
+
case 36: // Enter
|
|
87
|
+
self?.state?.activateSelected()
|
|
88
|
+
self?.dismiss()
|
|
89
|
+
return nil
|
|
90
|
+
default:
|
|
91
|
+
return event
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func dismiss() {
|
|
97
|
+
panel?.orderOut(nil)
|
|
98
|
+
panel = nil
|
|
99
|
+
state = nil
|
|
100
|
+
if let monitor = keyMonitor {
|
|
101
|
+
NSEvent.removeMonitor(monitor)
|
|
102
|
+
keyMonitor = nil
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -364,9 +364,19 @@ enum CommandBuilder {
|
|
|
364
364
|
}
|
|
365
365
|
))
|
|
366
366
|
|
|
367
|
+
commands.append(PaletteCommand(
|
|
368
|
+
id: "app-windows-list",
|
|
369
|
+
title: "Windows List",
|
|
370
|
+
subtitle: "Browse all windows across displays",
|
|
371
|
+
icon: "rectangle.split.2x1",
|
|
372
|
+
category: .app,
|
|
373
|
+
badge: nil,
|
|
374
|
+
action: { CommandModeWindow.shared.show() }
|
|
375
|
+
))
|
|
376
|
+
|
|
367
377
|
commands.append(PaletteCommand(
|
|
368
378
|
id: "app-screen-map",
|
|
369
|
-
title: "
|
|
379
|
+
title: "Window Map",
|
|
370
380
|
subtitle: "Visual window editor",
|
|
371
381
|
icon: "rectangle.3.group",
|
|
372
382
|
category: .app,
|
|
@@ -13,14 +13,14 @@ final class PermissionChecker: ObservableObject {
|
|
|
13
13
|
|
|
14
14
|
var allGranted: Bool { accessibility && screenRecording }
|
|
15
15
|
|
|
16
|
-
/// Check current permission state
|
|
16
|
+
/// Check current permission state, prompting on first launch if not granted.
|
|
17
17
|
func check() {
|
|
18
18
|
let diag = DiagnosticLog.shared
|
|
19
19
|
|
|
20
20
|
let ax = AXIsProcessTrusted()
|
|
21
21
|
let sr = CGPreflightScreenCaptureAccess()
|
|
22
22
|
|
|
23
|
-
// First check: log
|
|
23
|
+
// First check: log identity info and prompt if needed
|
|
24
24
|
if !hasLoggedInitial {
|
|
25
25
|
hasLoggedInitial = true
|
|
26
26
|
let bundleId = Bundle.main.bundleIdentifier ?? "<no bundle id>"
|
|
@@ -30,6 +30,16 @@ final class PermissionChecker: ObservableObject {
|
|
|
30
30
|
diag.info("PermissionChecker: exec=\(execPath)")
|
|
31
31
|
diag.info("AXIsProcessTrusted() → \(ax)")
|
|
32
32
|
diag.info("CGPreflightScreenCaptureAccess() → \(sr)")
|
|
33
|
+
|
|
34
|
+
// Prompt for missing permissions on first check
|
|
35
|
+
if !ax {
|
|
36
|
+
requestAccessibility()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if !sr {
|
|
40
|
+
requestScreenRecording()
|
|
41
|
+
return
|
|
42
|
+
}
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
// Log on state changes
|
|
@@ -20,6 +20,32 @@ class Preferences: ObservableObject {
|
|
|
20
20
|
didSet { UserDefaults.standard.set(mode.rawValue, forKey: "mode") }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// MARK: - Search & OCR
|
|
24
|
+
|
|
25
|
+
@Published var ocrEnabled: Bool {
|
|
26
|
+
didSet { UserDefaults.standard.set(!ocrEnabled, forKey: "ocr.disabled") }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Published var ocrQuickInterval: Double {
|
|
30
|
+
didSet { UserDefaults.standard.set(ocrQuickInterval, forKey: "ocr.interval") }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Published var ocrDeepInterval: Double {
|
|
34
|
+
didSet { UserDefaults.standard.set(ocrDeepInterval, forKey: "ocr.deepInterval") }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Published var ocrQuickLimit: Int {
|
|
38
|
+
didSet { UserDefaults.standard.set(ocrQuickLimit, forKey: "ocr.quickLimit") }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Published var ocrDeepLimit: Int {
|
|
42
|
+
didSet { UserDefaults.standard.set(ocrDeepLimit, forKey: "ocr.deepLimit") }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@Published var ocrAccuracy: String {
|
|
46
|
+
didSet { UserDefaults.standard.set(ocrAccuracy, forKey: "ocr.accuracy") }
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
init() {
|
|
24
50
|
if let saved = UserDefaults.standard.string(forKey: "terminal"),
|
|
25
51
|
let t = Terminal(rawValue: saved), t.isInstalled {
|
|
@@ -44,5 +70,23 @@ class Preferences: ObservableObject {
|
|
|
44
70
|
} else {
|
|
45
71
|
self.mode = .learning
|
|
46
72
|
}
|
|
73
|
+
|
|
74
|
+
// Search & OCR
|
|
75
|
+
self.ocrEnabled = !UserDefaults.standard.bool(forKey: "ocr.disabled")
|
|
76
|
+
|
|
77
|
+
let savedInterval = UserDefaults.standard.double(forKey: "ocr.interval")
|
|
78
|
+
self.ocrQuickInterval = savedInterval > 0 ? savedInterval : 60
|
|
79
|
+
|
|
80
|
+
let savedDeep = UserDefaults.standard.double(forKey: "ocr.deepInterval")
|
|
81
|
+
self.ocrDeepInterval = savedDeep > 0 ? savedDeep : 7200
|
|
82
|
+
|
|
83
|
+
let savedQL = UserDefaults.standard.integer(forKey: "ocr.quickLimit")
|
|
84
|
+
self.ocrQuickLimit = savedQL > 0 ? savedQL : 5
|
|
85
|
+
|
|
86
|
+
let savedDL = UserDefaults.standard.integer(forKey: "ocr.deepLimit")
|
|
87
|
+
self.ocrDeepLimit = savedDL > 0 ? savedDL : 15
|
|
88
|
+
|
|
89
|
+
let savedAcc = UserDefaults.standard.string(forKey: "ocr.accuracy") ?? "accurate"
|
|
90
|
+
self.ocrAccuracy = savedAcc
|
|
47
91
|
}
|
|
48
92
|
}
|
|
@@ -1438,9 +1438,8 @@ final class ScreenMapController: ObservableObject {
|
|
|
1438
1438
|
// Tiling mode intercepts keys before anything else
|
|
1439
1439
|
if editor?.isTilingMode == true {
|
|
1440
1440
|
switch keyCode {
|
|
1441
|
-
case 53: // Escape
|
|
1442
|
-
|
|
1443
|
-
flash("Tiling cancelled")
|
|
1441
|
+
case 53: // Escape — always dismiss
|
|
1442
|
+
onDismiss?()
|
|
1444
1443
|
return true
|
|
1445
1444
|
case 123: // ← → left
|
|
1446
1445
|
tileSelectedWindowInEditor(to: .left)
|
|
@@ -1516,8 +1515,8 @@ final class ScreenMapController: ObservableObject {
|
|
|
1516
1515
|
// Search mode intercepts keys before normal handling
|
|
1517
1516
|
if isSearchActive {
|
|
1518
1517
|
switch keyCode {
|
|
1519
|
-
case 53: // Escape
|
|
1520
|
-
|
|
1518
|
+
case 53: // Escape — always dismiss
|
|
1519
|
+
onDismiss?()
|
|
1521
1520
|
return true
|
|
1522
1521
|
case 36: // Enter → select or focus
|
|
1523
1522
|
if modifiers.contains(.command) {
|
|
@@ -1539,18 +1538,9 @@ final class ScreenMapController: ObservableObject {
|
|
|
1539
1538
|
}
|
|
1540
1539
|
|
|
1541
1540
|
switch keyCode {
|
|
1542
|
-
case 53: // Escape
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
}
|
|
1546
|
-
if let ed = editor, ed.pendingEditCount > 0 {
|
|
1547
|
-
ed.discardEdits()
|
|
1548
|
-
diag.info("[ScreenMap] discarded edits")
|
|
1549
|
-
flash("Edits discarded")
|
|
1550
|
-
} else {
|
|
1551
|
-
diag.info("[ScreenMap] exit")
|
|
1552
|
-
onDismiss?()
|
|
1553
|
-
}
|
|
1541
|
+
case 53: // Escape — always dismiss
|
|
1542
|
+
diag.info("[ScreenMap] exit")
|
|
1543
|
+
onDismiss?()
|
|
1554
1544
|
return true
|
|
1555
1545
|
|
|
1556
1546
|
case 36: // Enter
|
|
@@ -1972,6 +1972,9 @@ struct ScreenMapView: View {
|
|
|
1972
1972
|
|
|
1973
1973
|
private func installKeyHandler() {
|
|
1974
1974
|
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { event in
|
|
1975
|
+
// Only handle keys when our window is the key window
|
|
1976
|
+
guard let win = ScreenMapWindowController.shared.nsWindow,
|
|
1977
|
+
win.isKeyWindow else { return event }
|
|
1975
1978
|
// Track space key for canvas drag-to-pan
|
|
1976
1979
|
if event.keyCode == 49 && !controller.isSearchActive {
|
|
1977
1980
|
if event.type == .keyDown && !event.isARepeat {
|