@lattices/cli 0.3.0 → 0.4.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 +85 -9
- package/app/Info.plist +30 -0
- package/app/Lattices.app/Contents/Info.plist +8 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
- package/app/Lattices.entitlements +15 -0
- package/app/Package.swift +8 -1
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +45 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +189 -6
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +802 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +49 -9
- package/app/Sources/IntentEngine.swift +962 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1275 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/MouseFinder.swift +222 -0
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +58 -45
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- package/app/Tests/StageDragTests.swift +333 -0
- package/app/Tests/StageJoinTests.swift +313 -0
- package/app/Tests/StageManagerTests.swift +280 -0
- package/app/Tests/StageTileTests.swift +353 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +740 -0
- package/bin/lattices-app.ts +338 -0
- package/bin/lattices-dev +208 -0
- package/bin/{lattices.js → lattices.ts} +777 -140
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agent-layer-guide.md +207 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +29 -11
- package/bin/client.js +0 -4
- package/bin/lattices-app.js +0 -221
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
// MARK: - HUDLeftBar
|
|
4
|
+
|
|
5
|
+
struct HUDLeftBar: View {
|
|
6
|
+
@ObservedObject var state: HUDState
|
|
7
|
+
@ObservedObject private var scanner = ProjectScanner.shared
|
|
8
|
+
@ObservedObject private var desktop = DesktopModel.shared
|
|
9
|
+
@ObservedObject private var workspace = WorkspaceManager.shared
|
|
10
|
+
@FocusState private var searchFieldFocused: Bool
|
|
11
|
+
@State private var resizeStartWidth: CGFloat?
|
|
12
|
+
@State private var isSearchHovered: Bool = false
|
|
13
|
+
@State private var hoveredSectionKey: Int?
|
|
14
|
+
@State private var hoveredItemID: String?
|
|
15
|
+
@State private var hoveredLayerID: String?
|
|
16
|
+
@State private var previewClearWorkItem: DispatchWorkItem?
|
|
17
|
+
var onDismiss: () -> Void
|
|
18
|
+
|
|
19
|
+
// Section definitions: (number key, title, icon, items builder)
|
|
20
|
+
private struct SectionDef {
|
|
21
|
+
let key: Int // number-key jump
|
|
22
|
+
let title: String
|
|
23
|
+
let icon: String
|
|
24
|
+
let items: [HUDItem]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private var sections: [SectionDef] {
|
|
28
|
+
[
|
|
29
|
+
SectionDef(key: 1, title: "Projects", icon: "folder.fill", items: filteredProjects.map { .project($0) }),
|
|
30
|
+
SectionDef(key: 2, title: "Windows", icon: "macwindow", items: filteredWindows.map { .window($0) }),
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private var visibleItems: [HUDItem] {
|
|
35
|
+
sections
|
|
36
|
+
.filter { state.isSectionExpanded($0.key) }
|
|
37
|
+
.flatMap(\.items)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MARK: - Filters
|
|
41
|
+
|
|
42
|
+
private var filteredProjects: [Project] {
|
|
43
|
+
let q = state.query.lowercased()
|
|
44
|
+
if q.isEmpty { return scanner.projects }
|
|
45
|
+
return scanner.projects.filter {
|
|
46
|
+
$0.name.lowercased().contains(q) ||
|
|
47
|
+
$0.paneSummary.lowercased().contains(q)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// All desktop windows, sorted by z-order (front-to-back)
|
|
52
|
+
/// Filters out: Lattices itself, windows with no title, and windows whose title is just the app name
|
|
53
|
+
private var filteredWindows: [WindowEntry] {
|
|
54
|
+
let q = state.query.lowercased()
|
|
55
|
+
return desktop.allWindows()
|
|
56
|
+
.filter { $0.app != "Lattices" }
|
|
57
|
+
.filter { !$0.title.isEmpty }
|
|
58
|
+
.filter { $0.title != $0.app } // skip helper windows titled "Cursor", "Codex", etc.
|
|
59
|
+
.filter { q.isEmpty || $0.title.lowercased().contains(q) || $0.app.lowercased().contains(q) }
|
|
60
|
+
.sorted { lhs, rhs in
|
|
61
|
+
let lhsDate = desktop.lastInteractionDate(for: lhs.wid) ?? .distantPast
|
|
62
|
+
let rhsDate = desktop.lastInteractionDate(for: rhs.wid) ?? .distantPast
|
|
63
|
+
if lhsDate != rhsDate {
|
|
64
|
+
return lhsDate > rhsDate
|
|
65
|
+
}
|
|
66
|
+
return lhs.zIndex < rhs.zIndex
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// MARK: - Body
|
|
71
|
+
|
|
72
|
+
var body: some View {
|
|
73
|
+
VStack(spacing: 0) {
|
|
74
|
+
searchBar
|
|
75
|
+
|
|
76
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
77
|
+
|
|
78
|
+
// Tile mode banner
|
|
79
|
+
if state.tileMode {
|
|
80
|
+
HStack(spacing: 8) {
|
|
81
|
+
Image(systemName: "rectangle.split.2x2")
|
|
82
|
+
.font(.system(size: 11, weight: .medium))
|
|
83
|
+
.foregroundColor(Palette.running)
|
|
84
|
+
Text("TILE MODE")
|
|
85
|
+
.font(.system(size: 10, weight: .semibold))
|
|
86
|
+
.foregroundColor(Palette.running)
|
|
87
|
+
Spacer()
|
|
88
|
+
Text("H/J/K/L to place · ⎋ done")
|
|
89
|
+
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
|
90
|
+
.foregroundColor(Color.white.opacity(0.42))
|
|
91
|
+
}
|
|
92
|
+
.padding(.horizontal, 14)
|
|
93
|
+
.padding(.vertical, 8)
|
|
94
|
+
.background(Palette.running.opacity(0.08))
|
|
95
|
+
|
|
96
|
+
Rectangle().fill(Palette.running.opacity(0.3)).frame(height: 0.5)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Scrollable sections
|
|
100
|
+
ScrollViewReader { proxy in
|
|
101
|
+
ScrollView {
|
|
102
|
+
VStack(alignment: .leading, spacing: 16) {
|
|
103
|
+
// Sections
|
|
104
|
+
ForEach(sections, id: \.key) { sec in
|
|
105
|
+
sectionView(sec, proxy: proxy)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Layers (at bottom, scroll to find)
|
|
109
|
+
if let layers = workspace.config?.layers, !layers.isEmpty {
|
|
110
|
+
layersSection(layers)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
.padding(.vertical, 10)
|
|
114
|
+
.padding(.horizontal, 10)
|
|
115
|
+
}
|
|
116
|
+
.onChange(of: state.selectedIndex) { _ in
|
|
117
|
+
let items = visibleItems
|
|
118
|
+
if let item = items[safe: state.selectedIndex] {
|
|
119
|
+
proxy.scrollTo(item.id, anchor: .center)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
125
|
+
|
|
126
|
+
// Minimap (pinned at bottom, docked mode only)
|
|
127
|
+
if state.minimapMode == .docked {
|
|
128
|
+
minimapDocked
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
Rectangle().fill(Palette.border).frame(height: 0.5)
|
|
132
|
+
|
|
133
|
+
footer
|
|
134
|
+
}
|
|
135
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
136
|
+
.background(Palette.bgSidebar)
|
|
137
|
+
.overlay(alignment: .trailing) {
|
|
138
|
+
resizeHandle
|
|
139
|
+
}
|
|
140
|
+
.onAppear { syncState() }
|
|
141
|
+
.onChange(of: state.query) { _ in
|
|
142
|
+
state.selectedIndex = 0
|
|
143
|
+
syncState()
|
|
144
|
+
}
|
|
145
|
+
.onChange(of: state.expandedSections) { _ in
|
|
146
|
+
syncState()
|
|
147
|
+
}
|
|
148
|
+
.onChange(of: state.focus) { focus in
|
|
149
|
+
let shouldFocusSearch = focus == .search
|
|
150
|
+
guard searchFieldFocused != shouldFocusSearch else { return }
|
|
151
|
+
DispatchQueue.main.async {
|
|
152
|
+
searchFieldFocused = shouldFocusSearch
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
.onChange(of: searchFieldFocused) { isFocused in
|
|
156
|
+
if isFocused {
|
|
157
|
+
state.focus = .search
|
|
158
|
+
} else if state.focus == .search {
|
|
159
|
+
state.focus = .list
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
.onReceive(scanner.objectWillChange) { _ in
|
|
163
|
+
DispatchQueue.main.async { syncState() }
|
|
164
|
+
}
|
|
165
|
+
.onReceive(desktop.objectWillChange) { _ in
|
|
166
|
+
DispatchQueue.main.async { syncState() }
|
|
167
|
+
}
|
|
168
|
+
.task {
|
|
169
|
+
DispatchQueue.main.async {
|
|
170
|
+
searchFieldFocused = state.focus == .search
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// Push flat items + section offsets to state so HUDController's key handler can use them
|
|
176
|
+
private func syncState() {
|
|
177
|
+
state.syncAutoSectionDefaults(hasRunningProjects: scanner.projects.contains(where: \.isRunning))
|
|
178
|
+
let secs = sections
|
|
179
|
+
var flat = [HUDItem]()
|
|
180
|
+
var offsets = [Int: Int]()
|
|
181
|
+
for sec in secs {
|
|
182
|
+
guard state.isSectionExpanded(sec.key) else { continue }
|
|
183
|
+
if !sec.items.isEmpty {
|
|
184
|
+
offsets[sec.key] = flat.count
|
|
185
|
+
}
|
|
186
|
+
flat.append(contentsOf: sec.items)
|
|
187
|
+
}
|
|
188
|
+
state.flatItems = flat
|
|
189
|
+
state.sectionOffsets = offsets
|
|
190
|
+
state.reconcileSelection(with: flat)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private var resizeHandle: some View {
|
|
194
|
+
ZStack {
|
|
195
|
+
Color.clear
|
|
196
|
+
VStack(spacing: 4) {
|
|
197
|
+
Capsule()
|
|
198
|
+
.fill(Palette.borderLit.opacity(0.9))
|
|
199
|
+
.frame(width: 2, height: 28)
|
|
200
|
+
Capsule()
|
|
201
|
+
.fill(Palette.border.opacity(0.9))
|
|
202
|
+
.frame(width: 2, height: 18)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
.frame(width: 10)
|
|
206
|
+
.contentShape(Rectangle())
|
|
207
|
+
.gesture(
|
|
208
|
+
DragGesture(minimumDistance: 0)
|
|
209
|
+
.onChanged { value in
|
|
210
|
+
if resizeStartWidth == nil {
|
|
211
|
+
resizeStartWidth = state.leftSidebarWidth
|
|
212
|
+
}
|
|
213
|
+
let base = resizeStartWidth ?? state.leftSidebarWidth
|
|
214
|
+
state.setLeftSidebarWidth(base + value.translation.width)
|
|
215
|
+
}
|
|
216
|
+
.onEnded { _ in
|
|
217
|
+
resizeStartWidth = nil
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// MARK: - Search bar
|
|
223
|
+
|
|
224
|
+
private var searchBar: some View {
|
|
225
|
+
HStack(spacing: 10) {
|
|
226
|
+
Image(systemName: "magnifyingglass")
|
|
227
|
+
.font(.system(size: 13, weight: .medium))
|
|
228
|
+
.foregroundColor(state.focus == .search ? Palette.text : Palette.textMuted.opacity(0.85))
|
|
229
|
+
|
|
230
|
+
ZStack(alignment: .leading) {
|
|
231
|
+
if state.query.isEmpty {
|
|
232
|
+
Text(state.focus == .search ? "Type to search..." : "/ to search")
|
|
233
|
+
.font(.system(size: 13, weight: .regular))
|
|
234
|
+
.foregroundColor(Palette.textMuted)
|
|
235
|
+
.allowsHitTesting(false)
|
|
236
|
+
}
|
|
237
|
+
TextField("", text: $state.query)
|
|
238
|
+
.font(.system(size: 13, weight: .regular))
|
|
239
|
+
.foregroundColor(Palette.text)
|
|
240
|
+
.textFieldStyle(.plain)
|
|
241
|
+
.focused($searchFieldFocused)
|
|
242
|
+
.onTapGesture {
|
|
243
|
+
state.focus = .search
|
|
244
|
+
searchFieldFocused = true
|
|
245
|
+
}
|
|
246
|
+
.onSubmit { activateSelected() }
|
|
247
|
+
}
|
|
248
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
249
|
+
|
|
250
|
+
if !state.query.isEmpty {
|
|
251
|
+
Button {
|
|
252
|
+
state.query = ""
|
|
253
|
+
state.selectedIndex = 0
|
|
254
|
+
} label: {
|
|
255
|
+
Image(systemName: "xmark.circle.fill")
|
|
256
|
+
.font(.system(size: 12))
|
|
257
|
+
.foregroundColor(Palette.textMuted)
|
|
258
|
+
}
|
|
259
|
+
.buttonStyle(.plain)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
263
|
+
.padding(.horizontal, 16)
|
|
264
|
+
.padding(.vertical, 12)
|
|
265
|
+
.background(searchBarBackground)
|
|
266
|
+
.contentShape(Rectangle())
|
|
267
|
+
.onTapGesture {
|
|
268
|
+
state.focus = .search
|
|
269
|
+
searchFieldFocused = true
|
|
270
|
+
}
|
|
271
|
+
.onHover { isHovering in
|
|
272
|
+
isSearchHovered = isHovering
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// MARK: - Section
|
|
277
|
+
|
|
278
|
+
@ViewBuilder
|
|
279
|
+
private func sectionView(_ sec: SectionDef, proxy: ScrollViewProxy) -> some View {
|
|
280
|
+
if !sec.items.isEmpty {
|
|
281
|
+
let isExpanded = state.isSectionExpanded(sec.key)
|
|
282
|
+
let isHovered = hoveredSectionKey == sec.key
|
|
283
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
284
|
+
Button {
|
|
285
|
+
state.toggleSection(sec.key)
|
|
286
|
+
} label: {
|
|
287
|
+
HStack(spacing: 8) {
|
|
288
|
+
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
|
289
|
+
.font(.system(size: 8, weight: .bold))
|
|
290
|
+
.foregroundColor(Palette.textMuted)
|
|
291
|
+
.frame(width: 10, alignment: .center)
|
|
292
|
+
|
|
293
|
+
Image(systemName: sec.icon)
|
|
294
|
+
.font(.system(size: 10, weight: .medium))
|
|
295
|
+
.foregroundColor(Palette.textMuted)
|
|
296
|
+
Text(sec.title)
|
|
297
|
+
.font(.system(size: 11, weight: .semibold))
|
|
298
|
+
.foregroundColor(Palette.textMuted.opacity(0.9))
|
|
299
|
+
Text("\(sec.items.count)")
|
|
300
|
+
.font(.system(size: 10, weight: .medium))
|
|
301
|
+
.foregroundColor(Palette.textDim)
|
|
302
|
+
Spacer()
|
|
303
|
+
shortcutBadge("\(sec.key)")
|
|
304
|
+
}
|
|
305
|
+
.padding(.horizontal, 8)
|
|
306
|
+
.padding(.vertical, 4)
|
|
307
|
+
.background(
|
|
308
|
+
RoundedRectangle(cornerRadius: 6)
|
|
309
|
+
.fill(isHovered ? Palette.surface.opacity(0.65) : Color.clear)
|
|
310
|
+
)
|
|
311
|
+
.contentShape(Rectangle())
|
|
312
|
+
}
|
|
313
|
+
.buttonStyle(.plain)
|
|
314
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
315
|
+
.onHover { isHovering in
|
|
316
|
+
hoveredSectionKey = isHovering ? sec.key : (hoveredSectionKey == sec.key ? nil : hoveredSectionKey)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if isExpanded {
|
|
320
|
+
ForEach(sec.items) { item in
|
|
321
|
+
itemRow(item)
|
|
322
|
+
.id(item.id)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// MARK: - Item row
|
|
330
|
+
|
|
331
|
+
private func itemRow(_ item: HUDItem) -> some View {
|
|
332
|
+
let isSelected = state.selectedItem == item && state.focus == .list
|
|
333
|
+
let isMultiSelected = state.selectedItems.contains(item.id)
|
|
334
|
+
let isTiled = state.tileMode && {
|
|
335
|
+
if case .window(let w) = item { return state.tiledWindows.contains(w.wid) }
|
|
336
|
+
return false
|
|
337
|
+
}()
|
|
338
|
+
let subtitleText = subtitle(for: item)
|
|
339
|
+
let isHovered = hoveredItemID == item.id
|
|
340
|
+
let rowFill: Color = {
|
|
341
|
+
if isTiled { return Palette.running.opacity(0.12) }
|
|
342
|
+
if isMultiSelected { return Palette.surfaceHov.opacity(0.9) }
|
|
343
|
+
if isSelected { return Palette.surfaceHov }
|
|
344
|
+
if isHovered { return Palette.surface.opacity(0.92) }
|
|
345
|
+
if state.selectedItem == item { return Palette.surface.opacity(0.8) }
|
|
346
|
+
return Color.clear
|
|
347
|
+
}()
|
|
348
|
+
let rowStroke: Color = {
|
|
349
|
+
if isTiled { return Palette.running.opacity(0.45) }
|
|
350
|
+
if isMultiSelected { return Color.blue.opacity(0.25) }
|
|
351
|
+
if isSelected { return Palette.borderLit }
|
|
352
|
+
if isHovered { return Palette.border.opacity(0.9) }
|
|
353
|
+
return Color.clear
|
|
354
|
+
}()
|
|
355
|
+
|
|
356
|
+
return Button {
|
|
357
|
+
state.focus = .list
|
|
358
|
+
guard let idx = visibleItems.firstIndex(of: item) else { return }
|
|
359
|
+
|
|
360
|
+
let modifiers = NSEvent.modifierFlags.intersection([.shift, .command])
|
|
361
|
+
if modifiers.contains(.shift) {
|
|
362
|
+
state.selectRange(to: item, index: idx, in: visibleItems)
|
|
363
|
+
} else if modifiers.contains(.command) {
|
|
364
|
+
state.toggleSelection(item, index: idx, in: visibleItems)
|
|
365
|
+
} else {
|
|
366
|
+
state.selectSingle(item, index: idx)
|
|
367
|
+
state.pinInspector(item, source: "row")
|
|
368
|
+
}
|
|
369
|
+
} label: {
|
|
370
|
+
HStack(spacing: 10) {
|
|
371
|
+
statusDot(for: item)
|
|
372
|
+
|
|
373
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
374
|
+
Text(item.displayName)
|
|
375
|
+
.font(.system(size: 13, weight: .semibold))
|
|
376
|
+
.foregroundColor(Palette.text)
|
|
377
|
+
.lineLimit(1)
|
|
378
|
+
|
|
379
|
+
if let subtitleText {
|
|
380
|
+
Text(subtitleText)
|
|
381
|
+
.font(.system(size: 11, weight: .regular))
|
|
382
|
+
.foregroundColor(Palette.textDim)
|
|
383
|
+
.lineLimit(1)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
Spacer()
|
|
388
|
+
}
|
|
389
|
+
.padding(.horizontal, 10)
|
|
390
|
+
.padding(.vertical, 8)
|
|
391
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
392
|
+
.background(
|
|
393
|
+
RoundedRectangle(cornerRadius: 7)
|
|
394
|
+
.fill(rowFill)
|
|
395
|
+
.overlay(
|
|
396
|
+
RoundedRectangle(cornerRadius: 7)
|
|
397
|
+
.strokeBorder(rowStroke, lineWidth: 0.5)
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
.contentShape(Rectangle())
|
|
401
|
+
}
|
|
402
|
+
.buttonStyle(.plain)
|
|
403
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
404
|
+
.onHover { isHovering in
|
|
405
|
+
hoveredItemID = isHovering ? item.id : (hoveredItemID == item.id ? nil : hoveredItemID)
|
|
406
|
+
if isHovering {
|
|
407
|
+
previewClearWorkItem?.cancel()
|
|
408
|
+
state.hoveredPreviewItem = item
|
|
409
|
+
state.hoverPreviewAnchorScreenY = NSEvent.mouseLocation.y
|
|
410
|
+
prefetchPreview(for: item)
|
|
411
|
+
} else if state.hoveredPreviewItem == item {
|
|
412
|
+
let hoveredItemID = item.id
|
|
413
|
+
let clearWorkItem = DispatchWorkItem {
|
|
414
|
+
guard self.state.hoveredPreviewItem?.id == hoveredItemID,
|
|
415
|
+
!self.state.previewInteractionActive else { return }
|
|
416
|
+
self.state.hoveredPreviewItem = nil
|
|
417
|
+
self.state.hoverPreviewAnchorScreenY = nil
|
|
418
|
+
}
|
|
419
|
+
previewClearWorkItem = clearWorkItem
|
|
420
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: clearWorkItem)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private func statusDot(for item: HUDItem) -> some View {
|
|
426
|
+
Circle()
|
|
427
|
+
.fill(dotColor(for: item))
|
|
428
|
+
.frame(width: 7, height: 7)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private func dotColor(for item: HUDItem) -> Color {
|
|
432
|
+
switch item {
|
|
433
|
+
case .project(let p): return p.isRunning ? Palette.running : Palette.textMuted.opacity(0.3)
|
|
434
|
+
case .window: return Palette.textDim
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private func subtitle(for item: HUDItem) -> String? {
|
|
439
|
+
switch item {
|
|
440
|
+
case .project(let p):
|
|
441
|
+
return p.paneSummary.isEmpty ? nil : p.paneSummary
|
|
442
|
+
case .window(let w):
|
|
443
|
+
return w.app
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private func prefetchPreview(for item: HUDItem) {
|
|
448
|
+
switch item {
|
|
449
|
+
case .window(let window):
|
|
450
|
+
WindowPreviewStore.shared.load(window: window)
|
|
451
|
+
case .project(let project):
|
|
452
|
+
guard project.isRunning,
|
|
453
|
+
let window = desktop.windowForSession(project.sessionName) else { return }
|
|
454
|
+
WindowPreviewStore.shared.load(window: window)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// MARK: - Layers section (at bottom)
|
|
459
|
+
|
|
460
|
+
private func layersSection(_ layers: [Layer]) -> some View {
|
|
461
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
462
|
+
HStack(spacing: 6) {
|
|
463
|
+
Image(systemName: "square.stack.3d.up")
|
|
464
|
+
.font(.system(size: 10, weight: .medium))
|
|
465
|
+
.foregroundColor(Palette.textMuted)
|
|
466
|
+
Text("Layers")
|
|
467
|
+
.font(.system(size: 11, weight: .semibold))
|
|
468
|
+
.foregroundColor(Palette.textMuted.opacity(0.9))
|
|
469
|
+
Text("\(layers.count)")
|
|
470
|
+
.font(.system(size: 10, weight: .medium))
|
|
471
|
+
.foregroundColor(Palette.textDim)
|
|
472
|
+
Spacer()
|
|
473
|
+
shortcutBadge("[ ]")
|
|
474
|
+
}
|
|
475
|
+
.padding(.horizontal, 8)
|
|
476
|
+
.padding(.vertical, 4)
|
|
477
|
+
|
|
478
|
+
ForEach(Array(layers.enumerated()), id: \.element.id) { idx, layer in
|
|
479
|
+
let isActive = idx == workspace.activeLayerIndex
|
|
480
|
+
let counts = workspace.layerRunningCount(index: idx)
|
|
481
|
+
let isHovered = hoveredLayerID == layer.id
|
|
482
|
+
|
|
483
|
+
Button {
|
|
484
|
+
workspace.focusLayer(index: idx)
|
|
485
|
+
} label: {
|
|
486
|
+
HStack(spacing: 10) {
|
|
487
|
+
Circle()
|
|
488
|
+
.fill(isActive ? Palette.running : Palette.textMuted.opacity(0.2))
|
|
489
|
+
.frame(width: 7, height: 7)
|
|
490
|
+
|
|
491
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
492
|
+
Text(layer.label)
|
|
493
|
+
.font(.system(size: 13, weight: .semibold))
|
|
494
|
+
.foregroundColor(isActive ? Palette.text : Palette.textMuted)
|
|
495
|
+
.lineLimit(1)
|
|
496
|
+
|
|
497
|
+
Text("\(counts.running)/\(counts.total) projects")
|
|
498
|
+
.font(.system(size: 11, weight: .regular))
|
|
499
|
+
.foregroundColor(Palette.textDim)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
Spacer()
|
|
503
|
+
|
|
504
|
+
Text("\(idx + 1)")
|
|
505
|
+
.font(.system(size: 10, weight: .semibold, design: .monospaced))
|
|
506
|
+
.foregroundColor(isActive ? Palette.text : Palette.textDim)
|
|
507
|
+
.frame(width: 18, height: 18)
|
|
508
|
+
.background(
|
|
509
|
+
RoundedRectangle(cornerRadius: 4)
|
|
510
|
+
.fill(isActive ? Palette.running.opacity(0.15) : Palette.surface)
|
|
511
|
+
.overlay(
|
|
512
|
+
RoundedRectangle(cornerRadius: 4)
|
|
513
|
+
.strokeBorder(isActive ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
.padding(.horizontal, 10)
|
|
518
|
+
.padding(.vertical, 8)
|
|
519
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
520
|
+
.background(
|
|
521
|
+
RoundedRectangle(cornerRadius: 7)
|
|
522
|
+
.fill(isActive ? Palette.running.opacity(0.06) : (isHovered ? Palette.surface.opacity(0.75) : Color.clear))
|
|
523
|
+
.overlay(
|
|
524
|
+
RoundedRectangle(cornerRadius: 7)
|
|
525
|
+
.strokeBorder(isActive ? Palette.running.opacity(0.2) : (isHovered ? Palette.border : Color.clear), lineWidth: 0.5)
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
.contentShape(Rectangle())
|
|
529
|
+
}
|
|
530
|
+
.buttonStyle(.plain)
|
|
531
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
532
|
+
.onHover { isHovering in
|
|
533
|
+
hoveredLayerID = isHovering ? layer.id : (hoveredLayerID == layer.id ? nil : hoveredLayerID)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// MARK: - Docked minimap
|
|
540
|
+
|
|
541
|
+
private var minimapDocked: some View {
|
|
542
|
+
let screens = NSScreen.screens
|
|
543
|
+
let screen: NSScreen? = screens.isEmpty ? nil : screens.first
|
|
544
|
+
let mapWidth: CGFloat = 300 // full sidebar width minus padding
|
|
545
|
+
let mapHeight: CGFloat = 140
|
|
546
|
+
|
|
547
|
+
return VStack(alignment: .leading, spacing: 0) {
|
|
548
|
+
// Header
|
|
549
|
+
HStack(spacing: 0) {
|
|
550
|
+
Image(systemName: "map")
|
|
551
|
+
.font(.system(size: 9, weight: .medium))
|
|
552
|
+
.foregroundColor(Palette.textMuted)
|
|
553
|
+
Text("Map")
|
|
554
|
+
.font(.system(size: 10, weight: .semibold))
|
|
555
|
+
.foregroundColor(Palette.textMuted.opacity(0.9))
|
|
556
|
+
.padding(.leading, 4)
|
|
557
|
+
|
|
558
|
+
Spacer()
|
|
559
|
+
|
|
560
|
+
// Expand out to canvas
|
|
561
|
+
Button {
|
|
562
|
+
state.minimapMode = .expanded
|
|
563
|
+
} label: {
|
|
564
|
+
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
|
565
|
+
.font(.system(size: 8, weight: .bold))
|
|
566
|
+
.foregroundColor(Palette.textMuted)
|
|
567
|
+
.frame(width: 18, height: 18)
|
|
568
|
+
.background(
|
|
569
|
+
RoundedRectangle(cornerRadius: 3)
|
|
570
|
+
.fill(Palette.surface)
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
.buttonStyle(.plain)
|
|
574
|
+
.help("Expand map (M)")
|
|
575
|
+
|
|
576
|
+
// Hide
|
|
577
|
+
Button {
|
|
578
|
+
state.minimapMode = .hidden
|
|
579
|
+
} label: {
|
|
580
|
+
Image(systemName: "chevron.down")
|
|
581
|
+
.font(.system(size: 8, weight: .bold))
|
|
582
|
+
.foregroundColor(Palette.textMuted)
|
|
583
|
+
.frame(width: 18, height: 18)
|
|
584
|
+
.background(
|
|
585
|
+
RoundedRectangle(cornerRadius: 3)
|
|
586
|
+
.fill(Palette.surface)
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
.buttonStyle(.plain)
|
|
590
|
+
.help("Hide map (M)")
|
|
591
|
+
}
|
|
592
|
+
.padding(.horizontal, 10)
|
|
593
|
+
.padding(.vertical, 6)
|
|
594
|
+
|
|
595
|
+
// Map canvas
|
|
596
|
+
if let screen {
|
|
597
|
+
let sw = screen.frame.width
|
|
598
|
+
let sh = screen.frame.height
|
|
599
|
+
let scaleX = mapWidth / sw
|
|
600
|
+
let scaleY = mapHeight / sh
|
|
601
|
+
let scale = min(scaleX, scaleY)
|
|
602
|
+
let drawW = sw * scale
|
|
603
|
+
let drawH = sh * scale
|
|
604
|
+
let offsetX = (mapWidth - drawW) / 2
|
|
605
|
+
let offsetY = (mapHeight - drawH) / 2
|
|
606
|
+
let origin = screenCGOrigin(screen)
|
|
607
|
+
let wins = windowsOnScreen(0)
|
|
608
|
+
|
|
609
|
+
ZStack(alignment: .topLeading) {
|
|
610
|
+
// Screen background
|
|
611
|
+
RoundedRectangle(cornerRadius: 4)
|
|
612
|
+
.fill(Palette.surface.opacity(0.4))
|
|
613
|
+
.frame(width: drawW, height: drawH)
|
|
614
|
+
.offset(x: offsetX, y: offsetY)
|
|
615
|
+
|
|
616
|
+
// Windows (back-to-front)
|
|
617
|
+
ForEach(wins.reversed()) { win in
|
|
618
|
+
let rx = (CGFloat(win.frame.x) - origin.x) * scale + offsetX
|
|
619
|
+
let ry = (CGFloat(win.frame.y) - origin.y) * scale + offsetY
|
|
620
|
+
let rw = CGFloat(win.frame.w) * scale
|
|
621
|
+
let rh = CGFloat(win.frame.h) * scale
|
|
622
|
+
let isSelected = state.selectedItem == .window(win)
|
|
623
|
+
|
|
624
|
+
RoundedRectangle(cornerRadius: 1.5)
|
|
625
|
+
.fill(appColor(win.app).opacity(isSelected ? 0.5 : 0.15))
|
|
626
|
+
.overlay(
|
|
627
|
+
RoundedRectangle(cornerRadius: 1.5)
|
|
628
|
+
.strokeBorder(
|
|
629
|
+
isSelected ? Palette.running : appColor(win.app).opacity(0.35),
|
|
630
|
+
lineWidth: isSelected ? 1.5 : 0.5
|
|
631
|
+
)
|
|
632
|
+
)
|
|
633
|
+
.overlay(
|
|
634
|
+
Group {
|
|
635
|
+
if rw > 24 && rh > 14 {
|
|
636
|
+
Text(String(win.app.prefix(1)))
|
|
637
|
+
.font(.system(size: max(6, min(9, rh * 0.35)), weight: .semibold, design: .monospaced))
|
|
638
|
+
.foregroundColor(appColor(win.app).opacity(isSelected ? 1.0 : 0.5))
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
)
|
|
642
|
+
.frame(width: max(rw, 3), height: max(rh, 2))
|
|
643
|
+
.offset(x: rx, y: ry)
|
|
644
|
+
.onTapGesture {
|
|
645
|
+
state.focus = .list
|
|
646
|
+
if let flatIdx = visibleItems.firstIndex(of: .window(win)) {
|
|
647
|
+
state.selectSingle(.window(win), index: flatIdx)
|
|
648
|
+
state.pinnedItem = .window(win)
|
|
649
|
+
state.hoveredPreviewItem = nil
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
.frame(width: mapWidth, height: mapHeight)
|
|
655
|
+
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
656
|
+
.padding(.horizontal, 10)
|
|
657
|
+
.padding(.bottom, 8)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// MARK: - Minimap helpers
|
|
663
|
+
|
|
664
|
+
private func screenCGOrigin(_ screen: NSScreen) -> (x: CGFloat, y: CGFloat) {
|
|
665
|
+
let primaryH = NSScreen.screens.first?.frame.height ?? 900
|
|
666
|
+
return (screen.frame.origin.x, primaryH - screen.frame.origin.y - screen.frame.height)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private func windowsOnScreen(_ screenIdx: Int) -> [WindowEntry] {
|
|
670
|
+
let screens = NSScreen.screens
|
|
671
|
+
guard screenIdx < screens.count else { return [] }
|
|
672
|
+
let screen = screens[screenIdx]
|
|
673
|
+
let origin = screenCGOrigin(screen)
|
|
674
|
+
let sw = Double(screen.frame.width)
|
|
675
|
+
let sh = Double(screen.frame.height)
|
|
676
|
+
|
|
677
|
+
return desktop.allWindows().filter { win in
|
|
678
|
+
let cx = win.frame.x + win.frame.w / 2
|
|
679
|
+
let cy = win.frame.y + win.frame.h / 2
|
|
680
|
+
return cx >= Double(origin.x) && cx < Double(origin.x) + sw &&
|
|
681
|
+
cy >= Double(origin.y) && cy < Double(origin.y) + sh &&
|
|
682
|
+
win.app != "Lattices"
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private func appColor(_ app: String) -> Color {
|
|
687
|
+
if ["iTerm2", "Terminal", "WezTerm", "Alacritty", "kitty"].contains(app) {
|
|
688
|
+
return Palette.running
|
|
689
|
+
}
|
|
690
|
+
if ["Google Chrome", "Safari", "Arc", "Firefox", "Brave Browser"].contains(app) {
|
|
691
|
+
return Color.blue
|
|
692
|
+
}
|
|
693
|
+
if ["Xcode", "Visual Studio Code", "Cursor", "Zed"].contains(app) {
|
|
694
|
+
return Color.purple
|
|
695
|
+
}
|
|
696
|
+
if app.localizedCaseInsensitiveContains("Claude") || app.localizedCaseInsensitiveContains("Codex") {
|
|
697
|
+
return Color.orange
|
|
698
|
+
}
|
|
699
|
+
return Palette.textMuted
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// MARK: - Footer
|
|
703
|
+
|
|
704
|
+
private var footer: some View {
|
|
705
|
+
let selectedIDs = state.effectiveSelectionIDs
|
|
706
|
+
let selectionCount = state.multiSelectionCount
|
|
707
|
+
let selectedProjects = visibleItems.compactMap { item -> Project? in
|
|
708
|
+
guard selectedIDs.contains(item.id), case .project(let project) = item else { return nil }
|
|
709
|
+
return project
|
|
710
|
+
}
|
|
711
|
+
let selectedWindows = visibleItems.compactMap { item -> WindowEntry? in
|
|
712
|
+
guard selectedIDs.contains(item.id), case .window(let window) = item else { return nil }
|
|
713
|
+
return window
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return HStack(spacing: 10) {
|
|
717
|
+
if selectionCount > 1 {
|
|
718
|
+
Text("\(selectionCount) selected")
|
|
719
|
+
.font(.system(size: 10, weight: .semibold))
|
|
720
|
+
.foregroundColor(Color.white.opacity(0.86))
|
|
721
|
+
if !selectedWindows.isEmpty || !selectedProjects.isEmpty {
|
|
722
|
+
keyBadge("T", label: "Tile")
|
|
723
|
+
}
|
|
724
|
+
if !selectedProjects.isEmpty {
|
|
725
|
+
keyBadge("D", label: "Detach")
|
|
726
|
+
} else if selectedWindows.count > 1 {
|
|
727
|
+
keyBadge("D", label: "Distrib")
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
keyBadge("⇧↕", label: "Range")
|
|
731
|
+
keyBadge("⌘", label: "Multi")
|
|
732
|
+
keyBadge("⇥", label: "Focus")
|
|
733
|
+
keyBadge("↵", label: "Go")
|
|
734
|
+
keyBadge("[ ]", label: "Layer")
|
|
735
|
+
|
|
736
|
+
Spacer()
|
|
737
|
+
|
|
738
|
+
if state.minimapMode != .docked {
|
|
739
|
+
Button {
|
|
740
|
+
state.minimapMode = .docked
|
|
741
|
+
} label: {
|
|
742
|
+
HStack(spacing: 3) {
|
|
743
|
+
Image(systemName: "map")
|
|
744
|
+
.font(.system(size: 8))
|
|
745
|
+
Text("M")
|
|
746
|
+
.font(.system(size: 9, weight: .semibold, design: .monospaced))
|
|
747
|
+
}
|
|
748
|
+
.foregroundColor(state.minimapMode == .expanded ? Palette.running : Palette.textMuted)
|
|
749
|
+
.padding(.horizontal, 6)
|
|
750
|
+
.padding(.vertical, 3)
|
|
751
|
+
.background(
|
|
752
|
+
RoundedRectangle(cornerRadius: 3)
|
|
753
|
+
.fill(Palette.surface)
|
|
754
|
+
.overlay(
|
|
755
|
+
RoundedRectangle(cornerRadius: 3)
|
|
756
|
+
.strokeBorder(state.minimapMode == .expanded ? Palette.running.opacity(0.3) : Palette.border, lineWidth: 0.5)
|
|
757
|
+
)
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
.buttonStyle(.plain)
|
|
761
|
+
.help("Dock map (M)")
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
.padding(.horizontal, 14)
|
|
765
|
+
.padding(.vertical, 7)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
private func keyBadge(_ key: String, label: String) -> some View {
|
|
769
|
+
HStack(spacing: 3) {
|
|
770
|
+
Text(key)
|
|
771
|
+
.font(.system(size: 9, weight: .semibold, design: .monospaced))
|
|
772
|
+
.foregroundColor(Palette.text)
|
|
773
|
+
.padding(.horizontal, 4)
|
|
774
|
+
.padding(.vertical, 2)
|
|
775
|
+
.background(
|
|
776
|
+
RoundedRectangle(cornerRadius: 3)
|
|
777
|
+
.fill(Palette.surface)
|
|
778
|
+
.overlay(
|
|
779
|
+
RoundedRectangle(cornerRadius: 3)
|
|
780
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
781
|
+
)
|
|
782
|
+
)
|
|
783
|
+
Text(label)
|
|
784
|
+
.font(.system(size: 10, weight: .medium))
|
|
785
|
+
.foregroundColor(Palette.textMuted)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private func shortcutBadge(_ key: String) -> some View {
|
|
790
|
+
Text(key)
|
|
791
|
+
.font(.system(size: 9, weight: .semibold, design: .monospaced))
|
|
792
|
+
.foregroundColor(Palette.textMuted)
|
|
793
|
+
.padding(.horizontal, 6)
|
|
794
|
+
.padding(.vertical, 3)
|
|
795
|
+
.background(
|
|
796
|
+
RoundedRectangle(cornerRadius: 4)
|
|
797
|
+
.fill(Palette.surface)
|
|
798
|
+
.overlay(
|
|
799
|
+
RoundedRectangle(cornerRadius: 4)
|
|
800
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
801
|
+
)
|
|
802
|
+
)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private var searchBarBackground: some View {
|
|
806
|
+
RoundedRectangle(cornerRadius: 8)
|
|
807
|
+
.fill(
|
|
808
|
+
state.focus == .search
|
|
809
|
+
? Palette.surface.opacity(0.6)
|
|
810
|
+
: (isSearchHovered ? Palette.surface.opacity(0.3) : Color.clear)
|
|
811
|
+
)
|
|
812
|
+
.overlay(
|
|
813
|
+
RoundedRectangle(cornerRadius: 8)
|
|
814
|
+
.strokeBorder(
|
|
815
|
+
state.focus == .search
|
|
816
|
+
? Palette.borderLit
|
|
817
|
+
: (isSearchHovered ? Palette.border.opacity(0.85) : Color.clear),
|
|
818
|
+
lineWidth: 0.5
|
|
819
|
+
)
|
|
820
|
+
)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// MARK: - Actions
|
|
824
|
+
|
|
825
|
+
private func activateSelected() {
|
|
826
|
+
guard let item = state.selectedItem else { return }
|
|
827
|
+
activate(item)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private func activate(_ item: HUDItem) {
|
|
831
|
+
switch item {
|
|
832
|
+
case .project(let p):
|
|
833
|
+
SessionManager.launch(project: p)
|
|
834
|
+
HandsOffSession.shared.playCachedCue(p.isRunning ? "Focused." : "Done.")
|
|
835
|
+
case .window(let w):
|
|
836
|
+
_ = WindowTiler.focusWindow(wid: w.wid, pid: w.pid)
|
|
837
|
+
HandsOffSession.shared.playCachedCue("Focused.")
|
|
838
|
+
}
|
|
839
|
+
onDismiss()
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// MARK: - Safe array subscript
|
|
844
|
+
|
|
845
|
+
private extension Array {
|
|
846
|
+
subscript(safe index: Int) -> Element? {
|
|
847
|
+
indices.contains(index) ? self[index] : nil
|
|
848
|
+
}
|
|
849
|
+
}
|