@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,192 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
/// NSPanel subclass that accepts key events and first-click mouse events.
|
|
5
|
+
/// Overrides sendEvent to ensure the panel is key before processing clicks,
|
|
6
|
+
/// which is required for SwiftUI gesture/button handling in .nonactivatingPanel panels.
|
|
7
|
+
private class CommandModePanel: NSPanel {
|
|
8
|
+
override var canBecomeKey: Bool { true }
|
|
9
|
+
override var canBecomeMain: Bool { true }
|
|
10
|
+
override var acceptsMouseMovedEvents: Bool {
|
|
11
|
+
get { true }
|
|
12
|
+
set { super.acceptsMouseMovedEvents = newValue }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
override func sendEvent(_ event: NSEvent) {
|
|
16
|
+
// Non-activating panels can silently lose key status. Re-assert key
|
|
17
|
+
// and app activation before every mouse-down so SwiftUI Buttons/gestures
|
|
18
|
+
// fire reliably — including the very first click after the panel appears.
|
|
19
|
+
if event.type == .leftMouseDown || event.type == .rightMouseDown {
|
|
20
|
+
if !NSApp.isActive {
|
|
21
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
22
|
+
}
|
|
23
|
+
if !isKeyWindow { makeKey() }
|
|
24
|
+
}
|
|
25
|
+
super.sendEvent(event)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// NSHostingView subclass that accepts first-click events in non-activating panels
|
|
30
|
+
private class FirstClickHostingView<Content: View>: NSHostingView<Content> {
|
|
31
|
+
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
|
32
|
+
override var focusRingType: NSFocusRingType { get { .none } set {} }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
final class CommandModeWindow {
|
|
36
|
+
static let shared = CommandModeWindow()
|
|
37
|
+
|
|
38
|
+
private var panel: NSPanel?
|
|
39
|
+
private var isOpen = false
|
|
40
|
+
|
|
41
|
+
/// Exposed for event monitor filtering (only handle clicks in this window)
|
|
42
|
+
var panelWindow: NSWindow? { panel }
|
|
43
|
+
|
|
44
|
+
var isVisible: Bool { isOpen }
|
|
45
|
+
|
|
46
|
+
func toggle() {
|
|
47
|
+
if isOpen {
|
|
48
|
+
dismiss()
|
|
49
|
+
} else {
|
|
50
|
+
show()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func show() {
|
|
55
|
+
// Always rebuild for fresh state
|
|
56
|
+
dismiss()
|
|
57
|
+
isOpen = true
|
|
58
|
+
|
|
59
|
+
// Dismiss palette if visible
|
|
60
|
+
if CommandPaletteWindow.shared.isVisible {
|
|
61
|
+
CommandPaletteWindow.shared.dismiss()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let state = CommandModeState()
|
|
65
|
+
state.onDismiss = { [weak self] in
|
|
66
|
+
self?.dismiss()
|
|
67
|
+
}
|
|
68
|
+
state.onPanelResize = { [weak self] width, height in
|
|
69
|
+
self?.animateResize(width: width, height: height)
|
|
70
|
+
}
|
|
71
|
+
state.enter()
|
|
72
|
+
|
|
73
|
+
// Compute initial size from state phase
|
|
74
|
+
let initialWidth: CGFloat
|
|
75
|
+
let initialHeight: CGFloat
|
|
76
|
+
if state.phase == .desktopInventory {
|
|
77
|
+
let displayCount = max(1, state.desktopSnapshot?.displays.count ?? 1)
|
|
78
|
+
let columnWidth: CGFloat = 480
|
|
79
|
+
initialWidth = CGFloat(displayCount) * columnWidth + CGFloat(displayCount - 1) + 32
|
|
80
|
+
initialHeight = 640
|
|
81
|
+
} else {
|
|
82
|
+
initialWidth = 580; initialHeight = 360
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let view = CommandModeView(state: state)
|
|
86
|
+
.preferredColorScheme(.dark)
|
|
87
|
+
|
|
88
|
+
let hosting = FirstClickHostingView(rootView: view)
|
|
89
|
+
hosting.translatesAutoresizingMaskIntoConstraints = false
|
|
90
|
+
|
|
91
|
+
let panel = CommandModePanel(
|
|
92
|
+
contentRect: NSRect(x: 0, y: 0, width: initialWidth, height: initialHeight),
|
|
93
|
+
styleMask: [.nonactivatingPanel],
|
|
94
|
+
backing: .buffered,
|
|
95
|
+
defer: false
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
panel.isOpaque = false
|
|
99
|
+
panel.backgroundColor = .clear
|
|
100
|
+
panel.hasShadow = true
|
|
101
|
+
panel.level = .floating
|
|
102
|
+
panel.isMovableByWindowBackground = true
|
|
103
|
+
panel.hidesOnDeactivate = true
|
|
104
|
+
panel.becomesKeyOnlyIfNeeded = false
|
|
105
|
+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
106
|
+
panel.isReleasedWhenClosed = false
|
|
107
|
+
|
|
108
|
+
let cornerRadius: CGFloat = 14
|
|
109
|
+
|
|
110
|
+
let effectView = NSVisualEffectView()
|
|
111
|
+
effectView.blendingMode = .behindWindow
|
|
112
|
+
effectView.material = .popover
|
|
113
|
+
effectView.state = .active
|
|
114
|
+
effectView.wantsLayer = true
|
|
115
|
+
effectView.maskImage = Self.maskImage(cornerRadius: cornerRadius)
|
|
116
|
+
|
|
117
|
+
panel.contentView = effectView
|
|
118
|
+
|
|
119
|
+
effectView.addSubview(hosting)
|
|
120
|
+
NSLayoutConstraint.activate([
|
|
121
|
+
hosting.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
|
|
122
|
+
hosting.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
|
|
123
|
+
hosting.topAnchor.constraint(equalTo: effectView.topAnchor),
|
|
124
|
+
hosting.bottomAnchor.constraint(equalTo: effectView.bottomAnchor),
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
// Center horizontally, slightly above vertical center
|
|
128
|
+
if let screen = NSScreen.main {
|
|
129
|
+
let screenFrame = screen.visibleFrame
|
|
130
|
+
let clampedWidth = min(initialWidth, screenFrame.width * 0.92)
|
|
131
|
+
let clampedHeight = min(initialHeight, screenFrame.height * 0.85)
|
|
132
|
+
let x = screenFrame.midX - clampedWidth / 2
|
|
133
|
+
let y = screenFrame.midY - clampedHeight / 2 + (screenFrame.height * 0.08)
|
|
134
|
+
panel.setFrameOrigin(NSPoint(x: x, y: y))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
self.panel = panel
|
|
138
|
+
|
|
139
|
+
panel.makeKeyAndOrderFront(nil)
|
|
140
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
141
|
+
AppDelegate.updateActivationPolicy()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private func animateResize(width: CGFloat, height: CGFloat) {
|
|
145
|
+
guard let panel = panel, let screen = panel.screen ?? NSScreen.main else { return }
|
|
146
|
+
|
|
147
|
+
let screenFrame = screen.visibleFrame
|
|
148
|
+
// Clamp to screen bounds with margin
|
|
149
|
+
let newWidth = min(width, screenFrame.width * 0.92)
|
|
150
|
+
let newHeight = min(height, screenFrame.height * 0.85)
|
|
151
|
+
|
|
152
|
+
let newX = screenFrame.midX - newWidth / 2
|
|
153
|
+
let newY = screenFrame.midY - newHeight / 2 + (screenFrame.height * 0.08)
|
|
154
|
+
|
|
155
|
+
let newFrame = NSRect(x: newX, y: newY, width: newWidth, height: newHeight)
|
|
156
|
+
|
|
157
|
+
NSAnimationContext.runAnimationGroup { ctx in
|
|
158
|
+
ctx.duration = 0.25
|
|
159
|
+
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
160
|
+
panel.animator().setFrame(newFrame, display: true)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func dismiss() {
|
|
165
|
+
isOpen = false
|
|
166
|
+
panel?.orderOut(nil)
|
|
167
|
+
panel = nil
|
|
168
|
+
AppDelegate.updateActivationPolicy()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Stretchable mask image for rounded corners
|
|
172
|
+
private static func maskImage(cornerRadius: CGFloat) -> NSImage {
|
|
173
|
+
let edgeLength = 2.0 * cornerRadius + 1.0
|
|
174
|
+
let maskImage = NSImage(
|
|
175
|
+
size: NSSize(width: edgeLength, height: edgeLength),
|
|
176
|
+
flipped: false
|
|
177
|
+
) { rect in
|
|
178
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
|
|
179
|
+
NSColor.black.set()
|
|
180
|
+
path.fill()
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
maskImage.capInsets = NSEdgeInsets(
|
|
184
|
+
top: cornerRadius,
|
|
185
|
+
left: cornerRadius,
|
|
186
|
+
bottom: cornerRadius,
|
|
187
|
+
right: cornerRadius
|
|
188
|
+
)
|
|
189
|
+
maskImage.resizingMode = .stretch
|
|
190
|
+
return maskImage
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import AppKit
|
|
3
|
+
|
|
4
|
+
struct CommandPaletteView: View {
|
|
5
|
+
let commands: [PaletteCommand]
|
|
6
|
+
let onDismiss: () -> Void
|
|
7
|
+
|
|
8
|
+
@State private var query = ""
|
|
9
|
+
@State private var selectedIndex = 0
|
|
10
|
+
@State private var eventMonitor: Any?
|
|
11
|
+
@FocusState private var isSearchFocused: Bool
|
|
12
|
+
|
|
13
|
+
private var filtered: [PaletteCommand] {
|
|
14
|
+
if query.isEmpty { return commands }
|
|
15
|
+
return commands
|
|
16
|
+
.map { ($0, $0.matchScore(query: query)) }
|
|
17
|
+
.filter { $0.1 > 0 }
|
|
18
|
+
.sorted { $0.1 > $1.1 }
|
|
19
|
+
.map(\.0)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Group commands by category (only used when query is empty)
|
|
23
|
+
private var grouped: [(PaletteCommand.Category, [PaletteCommand])] {
|
|
24
|
+
let items = filtered
|
|
25
|
+
var result: [(PaletteCommand.Category, [PaletteCommand])] = []
|
|
26
|
+
for cat in PaletteCommand.Category.allCases {
|
|
27
|
+
let group = items.filter { $0.category == cat }
|
|
28
|
+
if !group.isEmpty {
|
|
29
|
+
result.append((cat, group))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return result
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
var body: some View {
|
|
36
|
+
VStack(spacing: 0) {
|
|
37
|
+
// Search field
|
|
38
|
+
searchField
|
|
39
|
+
|
|
40
|
+
Rectangle()
|
|
41
|
+
.fill(Palette.border)
|
|
42
|
+
.frame(height: 0.5)
|
|
43
|
+
|
|
44
|
+
// Results
|
|
45
|
+
ScrollViewReader { proxy in
|
|
46
|
+
ScrollView {
|
|
47
|
+
if query.isEmpty {
|
|
48
|
+
groupedList
|
|
49
|
+
} else {
|
|
50
|
+
flatList
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
.onChange(of: selectedIndex) { idx in
|
|
54
|
+
let items = filtered
|
|
55
|
+
if idx >= 0 && idx < items.count {
|
|
56
|
+
proxy.scrollTo(items[idx].id, anchor: .center)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
.frame(minHeight: 280, maxHeight: 360)
|
|
61
|
+
|
|
62
|
+
Rectangle()
|
|
63
|
+
.fill(Palette.border)
|
|
64
|
+
.frame(height: 0.5)
|
|
65
|
+
|
|
66
|
+
// Footer hints
|
|
67
|
+
footer
|
|
68
|
+
}
|
|
69
|
+
.frame(width: 540)
|
|
70
|
+
.background(Palette.bg)
|
|
71
|
+
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
72
|
+
.overlay(
|
|
73
|
+
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
74
|
+
.strokeBorder(Palette.borderLit, lineWidth: 0.5)
|
|
75
|
+
)
|
|
76
|
+
.onAppear {
|
|
77
|
+
installKeyHandler()
|
|
78
|
+
// Delay focus slightly to ensure the panel is key
|
|
79
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
80
|
+
isSearchFocused = true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
.onDisappear { removeKeyHandler() }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// MARK: - Search Field
|
|
87
|
+
|
|
88
|
+
private var searchField: some View {
|
|
89
|
+
HStack(spacing: 10) {
|
|
90
|
+
Image(systemName: "magnifyingglass")
|
|
91
|
+
.foregroundColor(Palette.textMuted)
|
|
92
|
+
.font(.system(size: 14))
|
|
93
|
+
|
|
94
|
+
TextField("Search commands...", text: $query)
|
|
95
|
+
.textFieldStyle(.plain)
|
|
96
|
+
.font(Typo.body(14))
|
|
97
|
+
.foregroundColor(Palette.text)
|
|
98
|
+
.focused($isSearchFocused)
|
|
99
|
+
.onChange(of: query) { _ in selectedIndex = 0 }
|
|
100
|
+
}
|
|
101
|
+
.padding(.horizontal, 16)
|
|
102
|
+
.padding(.vertical, 12)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MARK: - Grouped List (empty query)
|
|
106
|
+
|
|
107
|
+
private var groupedList: some View {
|
|
108
|
+
LazyVStack(alignment: .leading, spacing: 0) {
|
|
109
|
+
ForEach(grouped, id: \.0) { category, items in
|
|
110
|
+
sectionHeader(category)
|
|
111
|
+
ForEach(items) { cmd in
|
|
112
|
+
let idx = flatIndex(of: cmd)
|
|
113
|
+
commandRow(cmd, isSelected: idx == selectedIndex)
|
|
114
|
+
.id(cmd.id)
|
|
115
|
+
.onTapGesture { executeCommand(cmd) }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
.padding(.vertical, 6)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// MARK: - Flat List (with query)
|
|
123
|
+
|
|
124
|
+
private var flatList: some View {
|
|
125
|
+
LazyVStack(alignment: .leading, spacing: 0) {
|
|
126
|
+
ForEach(Array(filtered.enumerated()), id: \.element.id) { idx, cmd in
|
|
127
|
+
commandRow(cmd, isSelected: idx == selectedIndex)
|
|
128
|
+
.id(cmd.id)
|
|
129
|
+
.onTapGesture { executeCommand(cmd) }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
.padding(.vertical, 6)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// MARK: - Section Header
|
|
136
|
+
|
|
137
|
+
private func sectionHeader(_ category: PaletteCommand.Category) -> some View {
|
|
138
|
+
HStack(spacing: 5) {
|
|
139
|
+
Image(systemName: category.icon)
|
|
140
|
+
.font(.system(size: 9))
|
|
141
|
+
Text(category.rawValue.uppercased())
|
|
142
|
+
.font(Typo.mono(9))
|
|
143
|
+
}
|
|
144
|
+
.foregroundColor(Palette.textMuted)
|
|
145
|
+
.padding(.horizontal, 16)
|
|
146
|
+
.padding(.top, 10)
|
|
147
|
+
.padding(.bottom, 4)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// MARK: - Command Row
|
|
151
|
+
|
|
152
|
+
private func commandRow(_ cmd: PaletteCommand, isSelected: Bool) -> some View {
|
|
153
|
+
HStack(spacing: 10) {
|
|
154
|
+
Image(systemName: cmd.icon)
|
|
155
|
+
.font(.system(size: 12))
|
|
156
|
+
.foregroundColor(isSelected ? Palette.text : Palette.textDim)
|
|
157
|
+
.frame(width: 20)
|
|
158
|
+
|
|
159
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
160
|
+
HStack(spacing: 6) {
|
|
161
|
+
Text(cmd.title)
|
|
162
|
+
.font(Typo.body(13))
|
|
163
|
+
.foregroundColor(isSelected ? Palette.text : Palette.text.opacity(0.85))
|
|
164
|
+
.lineLimit(1)
|
|
165
|
+
|
|
166
|
+
if let badge = cmd.badge {
|
|
167
|
+
Text(badge)
|
|
168
|
+
.font(Typo.mono(9))
|
|
169
|
+
.foregroundColor(Palette.running)
|
|
170
|
+
.padding(.horizontal, 5)
|
|
171
|
+
.padding(.vertical, 1)
|
|
172
|
+
.background(
|
|
173
|
+
RoundedRectangle(cornerRadius: 3)
|
|
174
|
+
.fill(Palette.running.opacity(0.12))
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
Text(cmd.subtitle)
|
|
180
|
+
.font(Typo.caption(10))
|
|
181
|
+
.foregroundColor(Palette.textMuted)
|
|
182
|
+
.lineLimit(1)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
Spacer()
|
|
186
|
+
}
|
|
187
|
+
.padding(.horizontal, 14)
|
|
188
|
+
.padding(.vertical, 7)
|
|
189
|
+
.background(
|
|
190
|
+
RoundedRectangle(cornerRadius: 5)
|
|
191
|
+
.fill(isSelected ? Palette.surface : Color.clear)
|
|
192
|
+
)
|
|
193
|
+
.padding(.horizontal, 6)
|
|
194
|
+
.contentShape(Rectangle())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// MARK: - Footer
|
|
198
|
+
|
|
199
|
+
private var footer: some View {
|
|
200
|
+
HStack(spacing: 14) {
|
|
201
|
+
footerHint(keys: ["\u{2191}\u{2193}"], label: "navigate")
|
|
202
|
+
footerHint(keys: ["\u{21A9}"], label: "select")
|
|
203
|
+
footerHint(keys: ["esc"], label: "close")
|
|
204
|
+
Spacer()
|
|
205
|
+
Text("\(filtered.count) command\(filtered.count == 1 ? "" : "s")")
|
|
206
|
+
.font(Typo.mono(9))
|
|
207
|
+
.foregroundColor(Palette.textMuted)
|
|
208
|
+
}
|
|
209
|
+
.padding(.horizontal, 16)
|
|
210
|
+
.padding(.vertical, 8)
|
|
211
|
+
.background(Palette.surface.opacity(0.4))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private func footerHint(keys: [String], label: String) -> some View {
|
|
215
|
+
HStack(spacing: 4) {
|
|
216
|
+
ForEach(keys, id: \.self) { key in
|
|
217
|
+
Text(key)
|
|
218
|
+
.font(Typo.mono(9))
|
|
219
|
+
.foregroundColor(Palette.text)
|
|
220
|
+
.padding(.horizontal, 4)
|
|
221
|
+
.padding(.vertical, 2)
|
|
222
|
+
.background(
|
|
223
|
+
RoundedRectangle(cornerRadius: 3)
|
|
224
|
+
.fill(Palette.surface)
|
|
225
|
+
.overlay(
|
|
226
|
+
RoundedRectangle(cornerRadius: 3)
|
|
227
|
+
.strokeBorder(Palette.border, lineWidth: 0.5)
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
Text(label)
|
|
232
|
+
.font(Typo.mono(9))
|
|
233
|
+
.foregroundColor(Palette.textMuted)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// MARK: - Keyboard Navigation
|
|
238
|
+
|
|
239
|
+
private func installKeyHandler() {
|
|
240
|
+
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
|
241
|
+
let isSearchActive = isSearchFocused
|
|
242
|
+
|
|
243
|
+
switch Int(event.keyCode) {
|
|
244
|
+
case 125: // Down arrow
|
|
245
|
+
moveDown()
|
|
246
|
+
return nil
|
|
247
|
+
case 126: // Up arrow
|
|
248
|
+
moveUp()
|
|
249
|
+
return nil
|
|
250
|
+
case 38 where !isSearchActive: // j (vim down) — only when not typing
|
|
251
|
+
moveDown()
|
|
252
|
+
return nil
|
|
253
|
+
case 40 where !isSearchActive: // k (vim up) — only when not typing
|
|
254
|
+
moveUp()
|
|
255
|
+
return nil
|
|
256
|
+
case 36: // Return
|
|
257
|
+
let items = filtered
|
|
258
|
+
if selectedIndex >= 0 && selectedIndex < items.count {
|
|
259
|
+
executeCommand(items[selectedIndex])
|
|
260
|
+
}
|
|
261
|
+
return nil
|
|
262
|
+
case 53: // Escape
|
|
263
|
+
onDismiss()
|
|
264
|
+
return nil
|
|
265
|
+
default:
|
|
266
|
+
return event
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private func moveDown() {
|
|
272
|
+
let count = filtered.count
|
|
273
|
+
if count > 0 {
|
|
274
|
+
selectedIndex = min(selectedIndex + 1, count - 1)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private func moveUp() {
|
|
279
|
+
selectedIndex = max(selectedIndex - 1, 0)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private func removeKeyHandler() {
|
|
283
|
+
if let monitor = eventMonitor {
|
|
284
|
+
NSEvent.removeMonitor(monitor)
|
|
285
|
+
eventMonitor = nil
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// MARK: - Execution
|
|
290
|
+
|
|
291
|
+
private func executeCommand(_ cmd: PaletteCommand) {
|
|
292
|
+
let action = cmd.action
|
|
293
|
+
onDismiss()
|
|
294
|
+
// Small delay to let the palette dismiss before executing
|
|
295
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
296
|
+
action()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// MARK: - Helpers
|
|
301
|
+
|
|
302
|
+
/// Get flat index of a command across all groups (for selection tracking)
|
|
303
|
+
private func flatIndex(of cmd: PaletteCommand) -> Int {
|
|
304
|
+
let items = filtered
|
|
305
|
+
return items.firstIndex(where: { $0.id == cmd.id }) ?? -1
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
/// NSPanel subclass that accepts key events even without a titlebar
|
|
5
|
+
private class KeyablePanel: NSPanel {
|
|
6
|
+
override var canBecomeKey: Bool { true }
|
|
7
|
+
override var canBecomeMain: Bool { true }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
final class CommandPaletteWindow {
|
|
11
|
+
static let shared = CommandPaletteWindow()
|
|
12
|
+
|
|
13
|
+
private var panel: NSPanel?
|
|
14
|
+
private var scanner: ProjectScanner?
|
|
15
|
+
|
|
16
|
+
func configure(scanner: ProjectScanner) {
|
|
17
|
+
self.scanner = scanner
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var isVisible: Bool { panel?.isVisible ?? false }
|
|
21
|
+
|
|
22
|
+
func toggle() {
|
|
23
|
+
if let p = panel, p.isVisible {
|
|
24
|
+
dismiss()
|
|
25
|
+
} else {
|
|
26
|
+
show()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func show() {
|
|
31
|
+
// Always rebuild for fresh command state
|
|
32
|
+
dismiss()
|
|
33
|
+
|
|
34
|
+
guard let scanner = scanner else { return }
|
|
35
|
+
|
|
36
|
+
// Ensure projects are up to date (full scan if list is empty,
|
|
37
|
+
// e.g. palette opened via hotkey before main popover appeared)
|
|
38
|
+
if scanner.projects.isEmpty {
|
|
39
|
+
scanner.scan()
|
|
40
|
+
} else {
|
|
41
|
+
scanner.refreshStatus()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let commands = CommandBuilder.build(scanner: scanner)
|
|
45
|
+
let view = CommandPaletteView(commands: commands) { [weak self] in
|
|
46
|
+
self?.dismiss()
|
|
47
|
+
}
|
|
48
|
+
.preferredColorScheme(.dark)
|
|
49
|
+
|
|
50
|
+
let hosting = NSHostingView(rootView: view)
|
|
51
|
+
hosting.translatesAutoresizingMaskIntoConstraints = false
|
|
52
|
+
|
|
53
|
+
let panel = KeyablePanel(
|
|
54
|
+
contentRect: NSRect(x: 0, y: 0, width: 540, height: 440),
|
|
55
|
+
styleMask: [.nonactivatingPanel],
|
|
56
|
+
backing: .buffered,
|
|
57
|
+
defer: false
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
panel.isOpaque = false
|
|
61
|
+
panel.backgroundColor = .clear
|
|
62
|
+
panel.hasShadow = true
|
|
63
|
+
panel.level = .floating
|
|
64
|
+
panel.isMovableByWindowBackground = true
|
|
65
|
+
panel.hidesOnDeactivate = true
|
|
66
|
+
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
67
|
+
panel.isReleasedWhenClosed = false
|
|
68
|
+
|
|
69
|
+
// Use NSVisualEffectView as contentView with a maskImage to communicate
|
|
70
|
+
// the rounded shape to the window server (layer.cornerRadius only clips
|
|
71
|
+
// at the view level — the window backing store stays rectangular)
|
|
72
|
+
let cornerRadius: CGFloat = 14
|
|
73
|
+
|
|
74
|
+
let effectView = NSVisualEffectView()
|
|
75
|
+
effectView.blendingMode = .behindWindow
|
|
76
|
+
effectView.material = .popover
|
|
77
|
+
effectView.state = .active
|
|
78
|
+
effectView.wantsLayer = true
|
|
79
|
+
effectView.maskImage = Self.maskImage(cornerRadius: cornerRadius)
|
|
80
|
+
|
|
81
|
+
panel.contentView = effectView
|
|
82
|
+
|
|
83
|
+
effectView.addSubview(hosting)
|
|
84
|
+
NSLayoutConstraint.activate([
|
|
85
|
+
hosting.leadingAnchor.constraint(equalTo: effectView.leadingAnchor),
|
|
86
|
+
hosting.trailingAnchor.constraint(equalTo: effectView.trailingAnchor),
|
|
87
|
+
hosting.topAnchor.constraint(equalTo: effectView.topAnchor),
|
|
88
|
+
hosting.bottomAnchor.constraint(equalTo: effectView.bottomAnchor),
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
// Center horizontally, slightly above vertical center (Spotlight-style)
|
|
92
|
+
if let screen = NSScreen.main {
|
|
93
|
+
let screenFrame = screen.visibleFrame
|
|
94
|
+
let x = screenFrame.midX - 270
|
|
95
|
+
let y = screenFrame.midY - 220 + (screenFrame.height * 0.1)
|
|
96
|
+
panel.setFrameOrigin(NSPoint(x: x, y: y))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
panel.makeKeyAndOrderFront(nil)
|
|
100
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
101
|
+
|
|
102
|
+
self.panel = panel
|
|
103
|
+
AppDelegate.updateActivationPolicy()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func dismiss() {
|
|
107
|
+
panel?.orderOut(nil)
|
|
108
|
+
panel = nil
|
|
109
|
+
AppDelegate.updateActivationPolicy()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Stretchable mask image for rounded corners — capInsets preserve the
|
|
113
|
+
/// corner arcs while the center stretches to any window size
|
|
114
|
+
private static func maskImage(cornerRadius: CGFloat) -> NSImage {
|
|
115
|
+
let edgeLength = 2.0 * cornerRadius + 1.0
|
|
116
|
+
let maskImage = NSImage(
|
|
117
|
+
size: NSSize(width: edgeLength, height: edgeLength),
|
|
118
|
+
flipped: false
|
|
119
|
+
) { rect in
|
|
120
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius)
|
|
121
|
+
NSColor.black.set()
|
|
122
|
+
path.fill()
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
maskImage.capInsets = NSEdgeInsets(
|
|
126
|
+
top: cornerRadius,
|
|
127
|
+
left: cornerRadius,
|
|
128
|
+
bottom: cornerRadius,
|
|
129
|
+
right: cornerRadius
|
|
130
|
+
)
|
|
131
|
+
maskImage.resizingMode = .stretch
|
|
132
|
+
return maskImage
|
|
133
|
+
}
|
|
134
|
+
}
|