@arach/lattices 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +1 -40
- package/app/Sources/AppDelegate.swift +154 -24
- package/app/Sources/CheatSheetHUD.swift +1 -0
- package/app/Sources/CommandModeState.swift +40 -19
- package/app/Sources/CommandModeView.swift +27 -2
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DiagnosticLog.swift +19 -1
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HotkeyManager.swift +1 -0
- package/app/Sources/HotkeyStore.swift +9 -1
- package/app/Sources/LatticesApi.swift +210 -0
- package/app/Sources/MainView.swift +46 -86
- package/app/Sources/MainWindow.swift +13 -0
- package/app/Sources/OcrModel.swift +309 -0
- package/app/Sources/OcrStore.swift +295 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/PaletteCommand.swift +11 -1
- package/app/Sources/PermissionChecker.swift +12 -2
- package/app/Sources/Preferences.swift +44 -0
- package/app/Sources/ScreenMapState.swift +7 -17
- package/app/Sources/ScreenMapView.swift +3 -0
- package/app/Sources/SettingsView.swift +534 -122
- package/app/Sources/Theme.swift +39 -0
- package/app/Sources/WindowTiler.swift +59 -56
- package/bin/lattices-app.js +23 -7
- package/bin/lattices.js +123 -0
- package/docs/api.md +390 -249
- package/docs/app.md +75 -28
- package/docs/concepts.md +45 -136
- package/docs/config.md +8 -7
- package/docs/layers.md +16 -18
- package/docs/ocr.md +185 -0
- package/docs/overview.md +39 -34
- package/docs/quickstart.md +34 -35
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
1
|
<picture>
|
|
2
|
-
<img alt="
|
|
2
|
+
<img alt="lattices" src="site/public/og.png" />
|
|
3
3
|
</picture>
|
|
4
4
|
|
|
5
|
-
#
|
|
5
|
+
# lattices
|
|
6
6
|
|
|
7
7
|
Declarative tmux sessions for developers.
|
|
8
8
|
|
|
9
|
-
One command to create a named tmux session with your tools running. Auto-detects your stack, fully configurable with `.
|
|
9
|
+
One command to create a named tmux session with your tools running. Auto-detects your stack, fully configurable with `.lattices.json`, and a native macOS menu bar app.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
npm install -g
|
|
14
|
+
npm install -g @arach/lattices
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
## Quick start
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
20
|
cd my-project
|
|
21
|
-
|
|
21
|
+
lattices
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
That's it.
|
|
24
|
+
That's it. lattices creates a tmux session named after your project with Claude Code on the left and your dev server on the right. It detects your package manager and dev command automatically.
|
|
25
25
|
|
|
26
26
|
## How it works
|
|
27
27
|
|
|
28
|
-
1. **Run `
|
|
28
|
+
1. **Run `lattices`** in any project directory
|
|
29
29
|
2. A named tmux session is created with configured panes
|
|
30
30
|
3. Commands start running in each pane immediately
|
|
31
|
-
4. Detach with `Ctrl+b d`, reattach by running `
|
|
31
|
+
4. Detach with `Ctrl+b d`, reattach by running `lattices` again
|
|
32
32
|
5. Sessions persist in the background until you kill them
|
|
33
33
|
|
|
34
34
|
## Configuration
|
|
35
35
|
|
|
36
|
-
Drop a `.
|
|
36
|
+
Drop a `.lattices.json` in your project root:
|
|
37
37
|
|
|
38
38
|
```json
|
|
39
39
|
{
|
|
@@ -75,7 +75,7 @@ Drop a `.lattice.json` in your project root:
|
|
|
75
75
|
|
|
76
76
|
## Auto-detection
|
|
77
77
|
|
|
78
|
-
Without a config file,
|
|
78
|
+
Without a config file, lattices reads your `package.json` and picks the right command:
|
|
79
79
|
|
|
80
80
|
- Checks `scripts.dev`, `scripts.start`, `scripts.serve`, `scripts.watch`
|
|
81
81
|
- Detects package manager from lock files (pnpm, bun, yarn, npm)
|
|
@@ -86,9 +86,9 @@ Without a config file, lattice reads your `package.json` and picks the right com
|
|
|
86
86
|
A macOS companion app for managing sessions without touching the terminal.
|
|
87
87
|
|
|
88
88
|
```sh
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
lattices app # Launch (builds from source or downloads binary)
|
|
90
|
+
lattices app build # Force rebuild from source
|
|
91
|
+
lattices app quit # Stop the menu bar app
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Features:
|
|
@@ -103,7 +103,7 @@ The app tries to compile from source first (requires Xcode CLI tools), falling b
|
|
|
103
103
|
## Tab groups
|
|
104
104
|
|
|
105
105
|
Bundle related projects as tabs within a single terminal window.
|
|
106
|
-
Configure in `~/.
|
|
106
|
+
Configure in `~/.lattices/workspace.json`:
|
|
107
107
|
|
|
108
108
|
```json
|
|
109
109
|
{
|
|
@@ -122,28 +122,28 @@ Configure in `~/.lattice/workspace.json`:
|
|
|
122
122
|
}
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
Each tab gets its own tmux window with pane layout from its `.
|
|
125
|
+
Each tab gets its own tmux window with pane layout from its `.lattices.json`.
|
|
126
126
|
|
|
127
127
|
```sh
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
lattices groups # List groups with status
|
|
129
|
+
lattices group talkie # Launch or attach
|
|
130
|
+
lattices tab talkie iOS # Switch to a tab
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
-
Groups can also be referenced in [workspace layers](https://
|
|
133
|
+
Groups can also be referenced in [workspace layers](https://lattices.dev/docs/layers) to tile a whole group into a screen position.
|
|
134
134
|
|
|
135
135
|
## CLI reference
|
|
136
136
|
|
|
137
137
|
```
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
lattices Create or reattach to a session for the current project
|
|
139
|
+
lattices init Generate a .lattices.json config
|
|
140
|
+
lattices ls List active tmux sessions
|
|
141
|
+
lattices kill [name] Kill a session (defaults to current project)
|
|
142
|
+
lattices group [id] List tab groups or launch/attach a group
|
|
143
|
+
lattices groups List all tab groups with status
|
|
144
|
+
lattices tab <group> [tab] Switch tab within a group (by label or index)
|
|
145
|
+
lattices app Launch the menu bar companion app
|
|
146
|
+
lattices help Show help
|
|
147
147
|
```
|
|
148
148
|
|
|
149
149
|
## Requirements
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// A single action row with shortcut badge, label, optional icon, and hotkey hint.
|
|
4
|
+
struct ActionRow: View {
|
|
5
|
+
let shortcut: String
|
|
6
|
+
let label: String
|
|
7
|
+
var hotkey: String? = nil
|
|
8
|
+
var icon: String? = nil
|
|
9
|
+
var accentColor: Color = Palette.textDim
|
|
10
|
+
var action: () -> Void
|
|
11
|
+
|
|
12
|
+
@State private var isHovered = false
|
|
13
|
+
|
|
14
|
+
var body: some View {
|
|
15
|
+
Button(action: action) {
|
|
16
|
+
HStack(spacing: 10) {
|
|
17
|
+
// Shortcut badge
|
|
18
|
+
Text(shortcut)
|
|
19
|
+
.font(Typo.monoBold(10))
|
|
20
|
+
.foregroundColor(accentColor)
|
|
21
|
+
.frame(width: 18, height: 18)
|
|
22
|
+
.background(
|
|
23
|
+
RoundedRectangle(cornerRadius: 4)
|
|
24
|
+
.fill(accentColor.opacity(0.12))
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// Icon
|
|
28
|
+
if let icon {
|
|
29
|
+
Image(systemName: icon)
|
|
30
|
+
.font(.system(size: 11, weight: .medium))
|
|
31
|
+
.foregroundColor(isHovered ? Palette.text : Palette.textDim)
|
|
32
|
+
.frame(width: 14)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Label
|
|
36
|
+
Text(label)
|
|
37
|
+
.font(Typo.mono(12))
|
|
38
|
+
.foregroundColor(isHovered ? Palette.text : Palette.textDim)
|
|
39
|
+
.lineLimit(1)
|
|
40
|
+
|
|
41
|
+
Spacer()
|
|
42
|
+
|
|
43
|
+
// Hotkey
|
|
44
|
+
if let hotkey {
|
|
45
|
+
Text(hotkey)
|
|
46
|
+
.font(Typo.mono(10))
|
|
47
|
+
.foregroundColor(Palette.textMuted)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
.padding(.horizontal, 10)
|
|
51
|
+
.padding(.vertical, 6)
|
|
52
|
+
.background(
|
|
53
|
+
RoundedRectangle(cornerRadius: 5)
|
|
54
|
+
.fill(isHovered ? Palette.surfaceHov : Color.clear)
|
|
55
|
+
)
|
|
56
|
+
.contentShape(Rectangle())
|
|
57
|
+
}
|
|
58
|
+
.buttonStyle(.plain)
|
|
59
|
+
.onHover { isHovered = $0 }
|
|
60
|
+
}
|
|
61
|
+
}
|
package/app/Sources/App.swift
CHANGED
|
@@ -3,47 +3,8 @@ import SwiftUI
|
|
|
3
3
|
@main
|
|
4
4
|
struct LatticesApp: App {
|
|
5
5
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
|
6
|
-
@StateObject private var scanner = ProjectScanner.shared
|
|
7
|
-
|
|
8
|
-
/// 3×3 grid icon for the menu bar — L-shape bright, rest dim (template for auto light/dark)
|
|
9
|
-
private static let menuBarIcon: NSImage = {
|
|
10
|
-
let size: CGFloat = 18
|
|
11
|
-
let img = NSImage(size: NSSize(width: size, height: size), flipped: false) { _ in
|
|
12
|
-
let pad: CGFloat = 2
|
|
13
|
-
let gap: CGFloat = 1.5
|
|
14
|
-
let cellSize = (size - 2 * pad - 2 * gap) / 3
|
|
15
|
-
|
|
16
|
-
// L-shape: left column + bottom row are solid, rest are dim
|
|
17
|
-
let solidCells: Set<Int> = [0, 3, 6, 7, 8] // (0,0),(1,0),(2,0),(2,1),(2,2)
|
|
18
|
-
|
|
19
|
-
for row in 0..<3 {
|
|
20
|
-
for col in 0..<3 {
|
|
21
|
-
let idx = row * 3 + col
|
|
22
|
-
let x = pad + CGFloat(col) * (cellSize + gap)
|
|
23
|
-
let y = pad + CGFloat(row) * (cellSize + gap)
|
|
24
|
-
let rect = NSRect(x: x, y: y, width: cellSize, height: cellSize)
|
|
25
|
-
|
|
26
|
-
if solidCells.contains(idx) {
|
|
27
|
-
NSColor.black.setFill()
|
|
28
|
-
} else {
|
|
29
|
-
NSColor.black.withAlphaComponent(0.25).setFill()
|
|
30
|
-
}
|
|
31
|
-
let path = NSBezierPath(roundedRect: rect, xRadius: 0.8, yRadius: 0.8)
|
|
32
|
-
path.fill()
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
return true
|
|
36
|
-
}
|
|
37
|
-
img.isTemplate = true
|
|
38
|
-
return img
|
|
39
|
-
}()
|
|
40
6
|
|
|
41
7
|
var body: some Scene {
|
|
42
|
-
|
|
43
|
-
MainView(scanner: scanner)
|
|
44
|
-
} label: {
|
|
45
|
-
Image(nsImage: Self.menuBarIcon)
|
|
46
|
-
}
|
|
47
|
-
.menuBarExtraStyle(.window)
|
|
8
|
+
Settings { EmptyView() }
|
|
48
9
|
}
|
|
49
10
|
}
|
|
@@ -1,18 +1,55 @@
|
|
|
1
1
|
import AppKit
|
|
2
|
+
import SwiftUI
|
|
2
3
|
|
|
3
|
-
///
|
|
4
|
-
///
|
|
5
|
-
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
4
|
+
/// Manages the NSStatusItem (menu bar icon), left-click popover, and right-click context menu.
|
|
5
|
+
/// Replaces the previous SwiftUI MenuBarExtra approach for full click-event control.
|
|
6
|
+
class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|
7
|
+
|
|
8
|
+
private var statusItem: NSStatusItem!
|
|
9
|
+
private var popover: NSPopover?
|
|
10
|
+
private var contextMenu: NSMenu!
|
|
11
|
+
|
|
12
|
+
/// 3×3 grid icon for the menu bar — L-shape bright, rest dim (template for auto light/dark)
|
|
13
|
+
private static let menuBarIcon: NSImage = {
|
|
14
|
+
let size: CGFloat = 18
|
|
15
|
+
let img = NSImage(size: NSSize(width: size, height: size), flipped: true) { _ in
|
|
16
|
+
let pad: CGFloat = 2
|
|
17
|
+
let gap: CGFloat = 1.5
|
|
18
|
+
let cellSize = (size - 2 * pad - 2 * gap) / 3
|
|
19
|
+
|
|
20
|
+
let solidCells: Set<Int> = [0, 3, 6, 7, 8]
|
|
21
|
+
|
|
22
|
+
for row in 0..<3 {
|
|
23
|
+
for col in 0..<3 {
|
|
24
|
+
let idx = row * 3 + col
|
|
25
|
+
let x = pad + CGFloat(col) * (cellSize + gap)
|
|
26
|
+
let y = pad + CGFloat(row) * (cellSize + gap)
|
|
27
|
+
let rect = NSRect(x: x, y: y, width: cellSize, height: cellSize)
|
|
28
|
+
|
|
29
|
+
if solidCells.contains(idx) {
|
|
30
|
+
NSColor.black.setFill()
|
|
31
|
+
} else {
|
|
32
|
+
NSColor.black.withAlphaComponent(0.25).setFill()
|
|
33
|
+
}
|
|
34
|
+
let path = NSBezierPath(roundedRect: rect, xRadius: 0.8, yRadius: 0.8)
|
|
35
|
+
path.fill()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
img.isTemplate = true
|
|
41
|
+
return img
|
|
42
|
+
}()
|
|
6
43
|
|
|
7
44
|
/// Toggle between .accessory (hidden from Dock/Cmd+Tab) and .regular (visible)
|
|
8
45
|
/// based on whether any managed windows are open.
|
|
9
|
-
/// Call this after showing or dismissing a window.
|
|
10
46
|
static func updateActivationPolicy() {
|
|
11
47
|
let hasVisibleWindow =
|
|
12
48
|
CommandModeWindow.shared.isVisible ||
|
|
13
49
|
CommandPaletteWindow.shared.isVisible ||
|
|
14
50
|
MainWindow.shared.isVisible ||
|
|
15
|
-
ScreenMapWindowController.shared.isVisible
|
|
51
|
+
ScreenMapWindowController.shared.isVisible ||
|
|
52
|
+
OmniSearchWindow.shared.isVisible
|
|
16
53
|
let desired: NSApplication.ActivationPolicy = hasVisibleWindow ? .regular : .accessory
|
|
17
54
|
if NSApp.activationPolicy() != desired {
|
|
18
55
|
NSApp.setActivationPolicy(desired)
|
|
@@ -26,14 +63,29 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
26
63
|
NSApp.setActivationPolicy(.accessory)
|
|
27
64
|
NSApp.appearance = NSAppearance(named: .darkAqua)
|
|
28
65
|
|
|
29
|
-
|
|
66
|
+
// --- Status item ---
|
|
67
|
+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
|
68
|
+
if let button = statusItem.button {
|
|
69
|
+
button.image = Self.menuBarIcon
|
|
70
|
+
button.action = #selector(statusItemClicked(_:))
|
|
71
|
+
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
|
72
|
+
button.target = self
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Context menu (right-click) ---
|
|
76
|
+
contextMenu = buildContextMenu()
|
|
77
|
+
|
|
78
|
+
// --- Hotkey registration ---
|
|
79
|
+
let scanner = ProjectScanner.shared
|
|
80
|
+
CommandPaletteWindow.shared.configure(scanner: scanner)
|
|
30
81
|
|
|
31
|
-
// Register all hotkeys via HotkeyStore (user-configurable bindings)
|
|
32
82
|
let store = HotkeyStore.shared
|
|
33
83
|
store.register(action: .palette) { CommandPaletteWindow.shared.toggle() }
|
|
34
84
|
store.register(action: .screenMap) { ScreenMapWindowController.shared.toggle() }
|
|
35
85
|
store.register(action: .bezel) { WindowBezel.showBezelForFrontmostWindow() }
|
|
36
86
|
store.register(action: .cheatSheet) { CheatSheetHUD.shared.toggle() }
|
|
87
|
+
store.register(action: .desktopInventory) { CommandModeWindow.shared.toggle() }
|
|
88
|
+
store.register(action: .omniSearch) { OmniSearchWindow.shared.toggle() }
|
|
37
89
|
|
|
38
90
|
// Layer-switching hotkeys
|
|
39
91
|
let workspace = WorkspaceManager.shared
|
|
@@ -58,21 +110,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
58
110
|
}
|
|
59
111
|
store.register(action: .tileDistribute) { WindowTiler.distributeVisible() }
|
|
60
112
|
|
|
61
|
-
// Style the MenuBarExtra panel when it appears
|
|
62
|
-
NotificationCenter.default.addObserver(
|
|
63
|
-
forName: NSWindow.didBecomeKeyNotification,
|
|
64
|
-
object: nil,
|
|
65
|
-
queue: .main
|
|
66
|
-
) { note in
|
|
67
|
-
guard let panel = note.object as? NSPanel else { return }
|
|
68
|
-
Self.stylePanel(panel)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
113
|
// Check macOS permissions (Accessibility, Screen Recording)
|
|
72
114
|
PermissionChecker.shared.check()
|
|
73
115
|
|
|
74
116
|
// Start daemon services
|
|
117
|
+
OcrStore.shared.open()
|
|
75
118
|
DesktopModel.shared.start()
|
|
119
|
+
OcrModel.shared.start()
|
|
76
120
|
TmuxModel.shared.start()
|
|
77
121
|
ProcessModel.shared.start()
|
|
78
122
|
LatticesApi.setup()
|
|
@@ -93,12 +137,98 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
93
137
|
}
|
|
94
138
|
}
|
|
95
139
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
140
|
+
// MARK: - Status item click handler
|
|
141
|
+
|
|
142
|
+
@objc private func statusItemClicked(_ sender: Any?) {
|
|
143
|
+
guard let event = NSApp.currentEvent, let button = statusItem.button else { return }
|
|
144
|
+
|
|
145
|
+
if event.type == .rightMouseUp {
|
|
146
|
+
// Right-click → context menu
|
|
147
|
+
contextMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
|
|
148
|
+
} else {
|
|
149
|
+
// Left-click → toggle popover
|
|
150
|
+
if let shown = popover, shown.isShown {
|
|
151
|
+
shown.performClose(sender)
|
|
152
|
+
} else {
|
|
153
|
+
let p = makePopover()
|
|
154
|
+
p.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
|
155
|
+
p.contentViewController?.view.window?.makeKey()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// Dismiss the popover programmatically (e.g. from the pop-out button).
|
|
161
|
+
func dismissPopover() {
|
|
162
|
+
popover?.performClose(nil)
|
|
103
163
|
}
|
|
164
|
+
|
|
165
|
+
/// Create a fresh popover each time so the SwiftUI view tree isn't kept alive
|
|
166
|
+
/// when the popover is closed — prevents continuous CPU usage from @Published updates.
|
|
167
|
+
private func makePopover() -> NSPopover {
|
|
168
|
+
let p = NSPopover()
|
|
169
|
+
p.contentViewController = NSHostingController(rootView: MainView(scanner: ProjectScanner.shared))
|
|
170
|
+
p.behavior = .transient
|
|
171
|
+
p.contentSize = NSSize(width: 380, height: 520)
|
|
172
|
+
p.appearance = NSAppearance(named: .darkAqua)
|
|
173
|
+
p.delegate = self
|
|
174
|
+
popover = p
|
|
175
|
+
return p
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func popoverDidClose(_ notification: Notification) {
|
|
179
|
+
// Tear down the SwiftUI view tree so observed models stop driving re-renders
|
|
180
|
+
popover?.contentViewController = nil
|
|
181
|
+
popover = nil
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// MARK: - Context menu
|
|
185
|
+
|
|
186
|
+
private func buildContextMenu() -> NSMenu {
|
|
187
|
+
let menu = NSMenu()
|
|
188
|
+
|
|
189
|
+
let actions: [(String, String, Selector)] = [
|
|
190
|
+
("Command Palette", "⌘⇧M", #selector(menuCommandPalette)),
|
|
191
|
+
("Screen Map", "", #selector(menuScreenMap)),
|
|
192
|
+
("Desktop Inventory", "", #selector(menuDesktopInventory)),
|
|
193
|
+
("Window Bezel", "", #selector(menuWindowBezel)),
|
|
194
|
+
("Cheat Sheet", "", #selector(menuCheatSheet)),
|
|
195
|
+
("Omni Search", "", #selector(menuOmniSearch)),
|
|
196
|
+
]
|
|
197
|
+
for (title, shortcut, action) in actions {
|
|
198
|
+
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
|
199
|
+
item.target = self
|
|
200
|
+
if !shortcut.isEmpty {
|
|
201
|
+
// Display-only; the actual hotkey is global
|
|
202
|
+
}
|
|
203
|
+
menu.addItem(item)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
menu.addItem(.separator())
|
|
207
|
+
|
|
208
|
+
let settings = NSMenuItem(title: "Settings…", action: #selector(menuSettings), keyEquivalent: ",")
|
|
209
|
+
settings.target = self
|
|
210
|
+
menu.addItem(settings)
|
|
211
|
+
|
|
212
|
+
let diag = NSMenuItem(title: "Diagnostics", action: #selector(menuDiagnostics), keyEquivalent: "")
|
|
213
|
+
diag.target = self
|
|
214
|
+
menu.addItem(diag)
|
|
215
|
+
|
|
216
|
+
menu.addItem(.separator())
|
|
217
|
+
|
|
218
|
+
let quit = NSMenuItem(title: "Quit Lattices", action: #selector(menuQuit), keyEquivalent: "q")
|
|
219
|
+
quit.target = self
|
|
220
|
+
menu.addItem(quit)
|
|
221
|
+
|
|
222
|
+
return menu
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@objc private func menuCommandPalette() { CommandPaletteWindow.shared.toggle() }
|
|
226
|
+
@objc private func menuScreenMap() { ScreenMapWindowController.shared.toggle() }
|
|
227
|
+
@objc private func menuDesktopInventory() { CommandModeWindow.shared.toggle() }
|
|
228
|
+
@objc private func menuWindowBezel() { WindowBezel.showBezelForFrontmostWindow() }
|
|
229
|
+
@objc private func menuCheatSheet() { CheatSheetHUD.shared.toggle() }
|
|
230
|
+
@objc private func menuOmniSearch() { OmniSearchWindow.shared.toggle() }
|
|
231
|
+
@objc private func menuSettings() { SettingsWindowController.shared.show() }
|
|
232
|
+
@objc private func menuDiagnostics() { DiagnosticWindow.shared.toggle() }
|
|
233
|
+
@objc private func menuQuit() { NSApp.terminate(nil) }
|
|
104
234
|
}
|
|
@@ -261,7 +261,9 @@ final class CommandModeState: ObservableObject {
|
|
|
261
261
|
let matchesApp = win.appName?.lowercased().contains(query) ?? false
|
|
262
262
|
let matchesTitle = win.title.lowercased().contains(query)
|
|
263
263
|
let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
|
|
264
|
-
|
|
264
|
+
let matchesOcr = OcrModel.shared.results[win.id]?.fullText
|
|
265
|
+
.lowercased().contains(query) ?? false
|
|
266
|
+
if !matchesApp && !matchesTitle && !matchesLattices && !matchesOcr { return false }
|
|
265
267
|
}
|
|
266
268
|
|
|
267
269
|
return true
|
|
@@ -305,6 +307,37 @@ final class CommandModeState: ObservableObject {
|
|
|
305
307
|
filteredSnapshot?.allWindows ?? []
|
|
306
308
|
}
|
|
307
309
|
|
|
310
|
+
var ocrMatchSnippets: [UInt32: String] {
|
|
311
|
+
guard isSearching, !searchQuery.isEmpty else { return [:] }
|
|
312
|
+
let query = searchQuery.lowercased()
|
|
313
|
+
let ocrResults = OcrModel.shared.results
|
|
314
|
+
var snippets: [UInt32: String] = [:]
|
|
315
|
+
for win in flatWindowList {
|
|
316
|
+
// Only show snippet if match came from OCR, not title/app
|
|
317
|
+
let matchesApp = win.appName?.lowercased().contains(query) ?? false
|
|
318
|
+
let matchesTitle = win.title.lowercased().contains(query)
|
|
319
|
+
let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
|
|
320
|
+
if matchesApp || matchesTitle || matchesLattices { continue }
|
|
321
|
+
if let ocr = ocrResults[win.id],
|
|
322
|
+
let range = ocr.fullText.lowercased().range(of: query) {
|
|
323
|
+
snippets[win.id] = Self.extractSnippet(from: ocr.fullText, around: range)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return snippets
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private static func extractSnippet(from text: String, around range: Range<String.Index>, maxLen: Int = 80) -> String {
|
|
330
|
+
let half = max(0, (maxLen - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
|
|
331
|
+
let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
|
|
332
|
+
let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
|
|
333
|
+
var s = String(text[start..<end])
|
|
334
|
+
.replacingOccurrences(of: "\n", with: " ")
|
|
335
|
+
.trimmingCharacters(in: .whitespaces)
|
|
336
|
+
if start > text.startIndex { s = "…" + s }
|
|
337
|
+
if end < text.endIndex { s += "…" }
|
|
338
|
+
return s
|
|
339
|
+
}
|
|
340
|
+
|
|
308
341
|
func enter() {
|
|
309
342
|
inventory = buildInventory()
|
|
310
343
|
chords = buildChords()
|
|
@@ -404,20 +437,8 @@ final class CommandModeState: ObservableObject {
|
|
|
404
437
|
}
|
|
405
438
|
|
|
406
439
|
switch keyCode {
|
|
407
|
-
case 53: // Escape
|
|
408
|
-
|
|
409
|
-
deactivateSearch()
|
|
410
|
-
return true
|
|
411
|
-
}
|
|
412
|
-
if !selectedWindowIds.isEmpty {
|
|
413
|
-
clearSelection()
|
|
414
|
-
return true
|
|
415
|
-
}
|
|
416
|
-
// No selection — back to chord view
|
|
417
|
-
desktopMode = .browsing
|
|
418
|
-
activePreset = nil
|
|
419
|
-
phase = .inventory
|
|
420
|
-
onPanelResize?(chordPanelSize.0, chordPanelSize.1)
|
|
440
|
+
case 53: // Escape — always dismiss
|
|
441
|
+
onDismiss?()
|
|
421
442
|
return true
|
|
422
443
|
|
|
423
444
|
case 126: // ↑
|
|
@@ -560,8 +581,8 @@ final class CommandModeState: ObservableObject {
|
|
|
560
581
|
|
|
561
582
|
private func handleTilingKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
|
|
562
583
|
switch keyCode {
|
|
563
|
-
case 53: // Escape
|
|
564
|
-
|
|
584
|
+
case 53: // Escape — always dismiss
|
|
585
|
+
onDismiss?()
|
|
565
586
|
return true
|
|
566
587
|
|
|
567
588
|
case 123: tileSelectedWindow(to: .left); return true // ←
|
|
@@ -593,8 +614,8 @@ final class CommandModeState: ObservableObject {
|
|
|
593
614
|
|
|
594
615
|
private func handleGridPreviewKey(_ keyCode: UInt16) -> Bool {
|
|
595
616
|
switch keyCode {
|
|
596
|
-
case 53: // Escape
|
|
597
|
-
|
|
617
|
+
case 53: // Escape — always dismiss
|
|
618
|
+
onDismiss?()
|
|
598
619
|
return true
|
|
599
620
|
|
|
600
621
|
case 36, 1: // Enter or s → apply the layout
|
|
@@ -282,13 +282,15 @@ struct CommandModeView: View {
|
|
|
282
282
|
Image(systemName: "magnifyingglass")
|
|
283
283
|
.font(.system(size: 11))
|
|
284
284
|
.foregroundColor(Palette.textDim)
|
|
285
|
-
TextField("Search windows
|
|
285
|
+
TextField("Search windows & content…", text: $state.searchQuery)
|
|
286
286
|
.textFieldStyle(.plain)
|
|
287
287
|
.font(Typo.mono(12))
|
|
288
288
|
.foregroundColor(Palette.text)
|
|
289
289
|
.focused($isSearchFieldFocused)
|
|
290
290
|
if !state.searchQuery.isEmpty {
|
|
291
|
-
|
|
291
|
+
let total = state.flatWindowList.count
|
|
292
|
+
let ocrCount = state.ocrMatchSnippets.count
|
|
293
|
+
Text(ocrCount > 0 ? "\(total) matches (\(ocrCount) by content)" : "\(total) matches")
|
|
292
294
|
.font(Typo.mono(9))
|
|
293
295
|
.foregroundColor(Palette.textMuted)
|
|
294
296
|
}
|
|
@@ -442,6 +444,7 @@ struct CommandModeView: View {
|
|
|
442
444
|
VStack(alignment: .leading, spacing: 0) {
|
|
443
445
|
if appGroup.windows.count == 1, let win = appGroup.windows.first {
|
|
444
446
|
inventoryRow(window: win, appLabel: appGroup.appName)
|
|
447
|
+
ocrSnippetRow(for: win.id)
|
|
445
448
|
if state.isSelected(win.id), let path = win.inventoryPath {
|
|
446
449
|
inventoryPathLabel(path)
|
|
447
450
|
}
|
|
@@ -454,6 +457,7 @@ struct CommandModeView: View {
|
|
|
454
457
|
.padding(.bottom, 1)
|
|
455
458
|
ForEach(appGroup.windows) { win in
|
|
456
459
|
inventoryRow(window: win, indented: true)
|
|
460
|
+
ocrSnippetRow(for: win.id)
|
|
457
461
|
if state.isSelected(win.id), let path = win.inventoryPath {
|
|
458
462
|
inventoryPathLabel(path)
|
|
459
463
|
}
|
|
@@ -471,6 +475,24 @@ struct CommandModeView: View {
|
|
|
471
475
|
.padding(.vertical, 2)
|
|
472
476
|
}
|
|
473
477
|
|
|
478
|
+
@ViewBuilder
|
|
479
|
+
private func ocrSnippetRow(for windowId: UInt32) -> some View {
|
|
480
|
+
if let snippet = state.ocrMatchSnippets[windowId] {
|
|
481
|
+
HStack(spacing: 4) {
|
|
482
|
+
Image(systemName: "text.magnifyingglass")
|
|
483
|
+
.font(.system(size: 7))
|
|
484
|
+
.foregroundColor(Palette.textMuted)
|
|
485
|
+
Text(snippet)
|
|
486
|
+
.font(Typo.mono(9).italic())
|
|
487
|
+
.foregroundColor(Palette.textMuted)
|
|
488
|
+
.lineLimit(1)
|
|
489
|
+
.truncationMode(.tail)
|
|
490
|
+
}
|
|
491
|
+
.padding(.horizontal, 28)
|
|
492
|
+
.padding(.vertical, 1)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
474
496
|
/// Unified inventory row — handles both single-app rows (with appLabel) and
|
|
475
497
|
/// sub-rows under a multi-window app header (with indented).
|
|
476
498
|
private func inventoryRow(
|
|
@@ -1278,6 +1300,9 @@ struct CommandModeView: View {
|
|
|
1278
1300
|
private func installKeyHandler() {
|
|
1279
1301
|
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
|
1280
1302
|
guard state.phase == .inventory || state.phase == .desktopInventory else { return event }
|
|
1303
|
+
// Only handle keys when our panel is the key window
|
|
1304
|
+
guard let panel = CommandModeWindow.shared.panelWindow,
|
|
1305
|
+
panel.isKeyWindow else { return event }
|
|
1281
1306
|
let consumed = state.handleKey(event.keyCode, modifiers: event.modifierFlags)
|
|
1282
1307
|
return consumed ? nil : event
|
|
1283
1308
|
}
|
|
@@ -386,6 +386,14 @@ final class DaemonServer: ObservableObject {
|
|
|
386
386
|
"pids": .array(interesting.map { .int($0) })
|
|
387
387
|
])
|
|
388
388
|
)
|
|
389
|
+
case .ocrScanComplete(let windowCount, let totalBlocks):
|
|
390
|
+
daemonEvent = DaemonEvent(
|
|
391
|
+
event: "ocr.scanComplete",
|
|
392
|
+
data: .object([
|
|
393
|
+
"windowCount": .int(windowCount),
|
|
394
|
+
"totalBlocks": .int(totalBlocks)
|
|
395
|
+
])
|
|
396
|
+
)
|
|
389
397
|
}
|
|
390
398
|
broadcast(daemonEvent)
|
|
391
399
|
}
|