- Add AppDiscovery provider for running app enumeration - Implement AppDropdownView with auto-launch functionality - Create SignalAction models for 40+ yabai commands - Build ActionBuilderView with nested parameter controls - Add LiveShellPreview for real-time shell command generation - Implement ActionValidator for conflict detection - Add migration parser for existing raw action strings - Include feature flag for safe rollout - Maintain full backward compatibility
396 lines
14 KiB
Swift
396 lines
14 KiB
Swift
//
|
|
// StatusBarController.swift
|
|
// YabaiPro
|
|
//
|
|
// Created by Jake Shore
|
|
// Copyright © 2024 Jake Shore. All rights reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import AppKit
|
|
|
|
class StatusBarController: NSObject, NSWindowDelegate {
|
|
private var statusItem: NSStatusItem?
|
|
private var popover: NSPopover?
|
|
private var settingsWindow: NSWindow?
|
|
private var settingsViewModel = ComprehensiveSettingsViewModel()
|
|
|
|
func showMenuBarItem() {
|
|
print("YabaiPro: ===== STARTING MENU BAR SETUP =====")
|
|
|
|
// Check if we have accessibility permission first
|
|
let hasPermission = PermissionsManager.shared.hasAccessibilityPermission
|
|
print("YabaiPro: Has accessibility permission: \(hasPermission)")
|
|
|
|
if !hasPermission {
|
|
print("YabaiPro: No permission - requesting it now...")
|
|
PermissionsManager.shared.requestAccessibilityPermission()
|
|
print("YabaiPro: Permission dialog should be showing")
|
|
// Give it a moment for the permission dialog
|
|
Thread.sleep(forTimeInterval: 3.0)
|
|
print("YabaiPro: Continuing after permission dialog")
|
|
} else {
|
|
print("YabaiPro: Permission already granted")
|
|
}
|
|
|
|
// Try to create status item
|
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
print("YabaiPro: Status item created: \(statusItem != nil)")
|
|
|
|
guard let statusItem = statusItem else {
|
|
print("YabaiPro: ERROR - Failed to create status item")
|
|
return
|
|
}
|
|
|
|
print("YabaiPro: Status item button: \(statusItem.button != nil)")
|
|
|
|
guard let button = statusItem.button else {
|
|
print("YabaiPro: ERROR - Status item has no button")
|
|
return
|
|
}
|
|
|
|
// Use a proper SF Symbol icon like other menu bar apps
|
|
if let image = NSImage(systemSymbolName: "rectangle.3.group", accessibilityDescription: "YabaiPro") {
|
|
button.image = image
|
|
} else {
|
|
// Fallback to text if SF Symbol not available
|
|
button.title = "⌘"
|
|
}
|
|
// Add startup indicator to tooltip
|
|
let isFromLaunchAgent = ProcessInfo.processInfo.environment["YABAIPRO_AUTO_LAUNCH"] == "true"
|
|
button.toolTip = "YabaiPro - Click to configure yabai" + (isFromLaunchAgent ? " (Auto-launched)" : "")
|
|
button.action = #selector(self.toggleSettingsWindow)
|
|
button.target = self
|
|
|
|
// Set the status bar button reference for transparency management
|
|
SystemTransparencyManager.shared.setStatusBarButton(button)
|
|
|
|
print("YabaiPro: Button configured with rectangle icon")
|
|
print("YabaiPro: Action: \(button.action != nil)")
|
|
print("YabaiPro: Target: \(button.target != nil)")
|
|
|
|
setupWindow()
|
|
print("YabaiPro: ===== MENU BAR SETUP COMPLETE =====")
|
|
}
|
|
|
|
private func setupWindow() {
|
|
let contentView = MainSettingsView()
|
|
.environmentObject(settingsViewModel)
|
|
|
|
let hostingController = NSHostingController(rootView: contentView)
|
|
|
|
settingsWindow = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 700, height: 600),
|
|
styleMask: [.titled, .closable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
settingsWindow?.center()
|
|
settingsWindow?.title = "YabaiPro"
|
|
settingsWindow?.contentViewController = hostingController
|
|
settingsWindow?.isReleasedWhenClosed = false
|
|
settingsWindow?.delegate = self
|
|
}
|
|
|
|
@objc func toggleSettingsWindow() {
|
|
print("YabaiPro: Toggle settings window called")
|
|
|
|
if settingsWindow == nil {
|
|
setupWindow()
|
|
}
|
|
|
|
if let window = settingsWindow {
|
|
if window.isVisible {
|
|
window.close()
|
|
print("YabaiPro: Settings window closed")
|
|
} else {
|
|
// Proper activation sequence for regular macOS app
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.makeMain()
|
|
window.makeFirstResponder(window.contentView)
|
|
|
|
// Ensure focus after window is shown
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.makeKey()
|
|
window.makeFirstResponder(window.contentView)
|
|
}
|
|
|
|
print("YabaiPro: Settings window shown with proper focus")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy popover support for backward compatibility
|
|
private func setupPopover() {
|
|
popover = NSPopover()
|
|
popover?.contentSize = NSSize(width: 320, height: 400)
|
|
popover?.behavior = .transient
|
|
popover?.contentViewController = NSHostingController(
|
|
rootView: SettingsView(viewModel: SettingsViewModel())
|
|
)
|
|
}
|
|
|
|
@objc func togglePopover() {
|
|
// For now, show the full settings window instead of popover
|
|
toggleSettingsWindow()
|
|
}
|
|
}
|
|
|
|
// MARK: - Enhanced Status Bar Controller
|
|
|
|
@objc class EnhancedStatusBarController: NSObject {
|
|
private var statusItem: NSStatusItem!
|
|
private var currentSpaceLabel: String = "?"
|
|
private var currentWindowTitle: String = ""
|
|
private var layoutMode: String = "bsp"
|
|
|
|
// Quick action buttons
|
|
private var spaceButton: NSButton!
|
|
private var layoutButton: NSButton!
|
|
|
|
private let commandRunner = YabaiCommandRunner()
|
|
|
|
func setupEnhancedStatusBar() {
|
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
|
|
// Create custom view with multiple elements
|
|
let customView = NSView(frame: NSRect(x: 0, y: 0, width: 120, height: 22))
|
|
|
|
// Space indicator
|
|
spaceButton = NSButton(title: "1", target: self, action: #selector(spaceClicked))
|
|
spaceButton.frame = NSRect(x: 0, y: 0, width: 25, height: 22)
|
|
spaceButton.bezelStyle = .inline
|
|
spaceButton.font = NSFont.systemFont(ofSize: 12)
|
|
customView.addSubview(spaceButton)
|
|
|
|
// Layout indicator
|
|
layoutButton = NSButton(title: "BSP", target: self, action: #selector(layoutClicked))
|
|
layoutButton.frame = NSRect(x: 25, y: 0, width: 35, height: 22)
|
|
layoutButton.bezelStyle = .inline
|
|
layoutButton.font = NSFont.systemFont(ofSize: 10)
|
|
customView.addSubview(layoutButton)
|
|
|
|
// Window counter
|
|
let windowLabel = NSTextField(labelWithString: "⊞")
|
|
windowLabel.frame = NSRect(x: 60, y: 0, width: 20, height: 22)
|
|
windowLabel.font = NSFont.systemFont(ofSize: 14)
|
|
customView.addSubview(windowLabel)
|
|
|
|
// Settings button
|
|
let settingsButton = NSButton(image: NSImage(systemSymbolName: "gear", accessibilityDescription: "Settings")!,
|
|
target: self, action: #selector(showSettings))
|
|
settingsButton.frame = NSRect(x: 80, y: 0, width: 22, height: 22)
|
|
settingsButton.bezelStyle = .inline
|
|
customView.addSubview(settingsButton)
|
|
|
|
statusItem.button?.addSubview(customView)
|
|
|
|
// Start status updates
|
|
startStatusUpdates()
|
|
}
|
|
|
|
@objc func spaceClicked() {
|
|
// Show space switcher
|
|
showSpaceSwitcher()
|
|
}
|
|
|
|
@objc func layoutClicked() {
|
|
// Cycle layout modes
|
|
cycleLayout()
|
|
}
|
|
|
|
@objc func showSettings() {
|
|
// Show main settings window
|
|
StatusBarController().toggleSettingsWindow()
|
|
}
|
|
|
|
func startStatusUpdates() {
|
|
Task {
|
|
while true {
|
|
await updateStatusInfo()
|
|
try await Task.sleep(nanoseconds: 1_000_000_000) // Update every second
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateStatusInfo() async {
|
|
do {
|
|
let spaces = try await commandRunner.querySpaces()
|
|
let windows = try await commandRunner.queryWindows()
|
|
|
|
// Parse current space and window info
|
|
await MainActor.run {
|
|
updateStatusDisplay(spaces: spaces, windows: windows)
|
|
}
|
|
} catch {
|
|
print("Failed to update status: \(error)")
|
|
}
|
|
}
|
|
|
|
func updateStatusDisplay(spaces: Data, windows: Data) {
|
|
// Parse JSON and update status bar
|
|
do {
|
|
let spacesArray = try JSONDecoder().decode([SpaceInfo].self, from: spaces)
|
|
_ = try JSONDecoder().decode([WindowInfo].self, from: windows)
|
|
|
|
// Find current space
|
|
if let currentSpace = spacesArray.first(where: { $0.focused == 1 }) {
|
|
currentSpaceLabel = "\(currentSpace.index)"
|
|
layoutMode = currentSpace.type
|
|
spaceButton.title = currentSpaceLabel
|
|
layoutButton.title = layoutMode.uppercased()
|
|
}
|
|
|
|
// Count windows in current space (could be used for status display)
|
|
// let windowCount = windowsArray.filter { $0.space == spacesArray.first(where: { $0.focused == 1 })?.index }.count
|
|
} catch {
|
|
print("Failed to parse status data: \(error)")
|
|
}
|
|
}
|
|
|
|
func showSpaceSwitcher() {
|
|
// Create a simple space switcher menu
|
|
let menu = NSMenu()
|
|
|
|
Task {
|
|
do {
|
|
let spaces = try await commandRunner.querySpaces()
|
|
let spacesArray = try JSONDecoder().decode([SpaceInfo].self, from: spaces)
|
|
|
|
for space in spacesArray {
|
|
let item = NSMenuItem(title: "Space \(space.index)", action: #selector(switchToSpace(_:)), keyEquivalent: "\(space.index)")
|
|
item.tag = Int(space.index)
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
|
|
menu.addItem(NSMenuItem.separator())
|
|
menu.addItem(NSMenuItem(title: "Create Space", action: #selector(createNewSpace), keyEquivalent: "n"))
|
|
menu.addItem(NSMenuItem(title: "Destroy Current Space", action: #selector(destroyCurrentSpace), keyEquivalent: "d"))
|
|
|
|
menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil)
|
|
} catch {
|
|
print("Failed to show space switcher: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func switchToSpace(_ sender: NSMenuItem) {
|
|
Task {
|
|
try await commandRunner.focusSpace(index: UInt32(sender.tag))
|
|
}
|
|
}
|
|
|
|
@objc func createNewSpace() {
|
|
Task {
|
|
_ = try await commandRunner.createSpace()
|
|
}
|
|
}
|
|
|
|
@objc func destroyCurrentSpace() {
|
|
Task {
|
|
// This would need to get current space index first
|
|
try await commandRunner.destroySpace(index: 1) // Placeholder
|
|
}
|
|
}
|
|
|
|
func cycleLayout() {
|
|
Task {
|
|
do {
|
|
let spaces = try await commandRunner.querySpaces()
|
|
let spacesArray = try JSONDecoder().decode([SpaceInfo].self, from: spaces)
|
|
|
|
if let currentSpace = spacesArray.first(where: { $0.focused == 1 }) {
|
|
let nextLayout: SpaceLayout
|
|
switch currentSpace.type {
|
|
case "bsp": nextLayout = .stack
|
|
case "stack": nextLayout = .float
|
|
case "float": nextLayout = .bsp
|
|
default: nextLayout = .bsp
|
|
}
|
|
|
|
try await commandRunner.setSpaceLayout(index: currentSpace.index, layout: nextLayout)
|
|
}
|
|
} catch {
|
|
print("Failed to cycle layout: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
func createQuickActionMenu() -> NSMenu {
|
|
let menu = NSMenu()
|
|
|
|
// Space management
|
|
let spaceMenu = NSMenu()
|
|
spaceMenu.addItem(NSMenuItem(title: "Create Space", action: #selector(createNewSpace), keyEquivalent: "n"))
|
|
spaceMenu.addItem(NSMenuItem(title: "Destroy Space", action: #selector(destroyCurrentSpace), keyEquivalent: "d"))
|
|
spaceMenu.addItem(NSMenuItem.separator())
|
|
|
|
// Add space switching items dynamically
|
|
Task {
|
|
do {
|
|
let spaces = try await commandRunner.querySpaces()
|
|
let spacesArray = try JSONDecoder().decode([SpaceInfo].self, from: spaces)
|
|
|
|
for space in spacesArray {
|
|
let item = NSMenuItem(title: "Space \(space.index)", action: #selector(switchToSpace(_:)), keyEquivalent: "\(space.index)")
|
|
item.tag = Int(space.index)
|
|
item.target = self
|
|
spaceMenu.addItem(item)
|
|
}
|
|
} catch {
|
|
print("Failed to load spaces for menu: \(error)")
|
|
}
|
|
}
|
|
|
|
let spaceMenuItem = NSMenuItem(title: "Spaces", action: nil, keyEquivalent: "")
|
|
spaceMenuItem.submenu = spaceMenu
|
|
menu.addItem(spaceMenuItem)
|
|
|
|
// Window management
|
|
let windowMenu = NSMenu()
|
|
windowMenu.addItem(NSMenuItem(title: "Float Window", action: #selector(toggleFloat), keyEquivalent: "f"))
|
|
windowMenu.addItem(NSMenuItem(title: "Fullscreen", action: #selector(toggleFullscreen), keyEquivalent: "m"))
|
|
windowMenu.addItem(NSMenuItem(title: "Stack Window", action: #selector(toggleStack), keyEquivalent: "s"))
|
|
|
|
let windowMenuItem = NSMenuItem(title: "Windows", action: nil, keyEquivalent: "")
|
|
windowMenuItem.submenu = windowMenu
|
|
menu.addItem(windowMenuItem)
|
|
|
|
return menu
|
|
}
|
|
|
|
@objc func toggleFloat() {
|
|
Task {
|
|
try await commandRunner.toggleWindowFloat()
|
|
}
|
|
}
|
|
|
|
@objc func toggleFullscreen() {
|
|
Task {
|
|
try await commandRunner.toggleWindowFullscreen()
|
|
}
|
|
}
|
|
|
|
@objc func toggleStack() {
|
|
Task {
|
|
try await commandRunner.toggleWindowStack()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Supporting structs for JSON parsing
|
|
struct SpaceInfo: Codable {
|
|
let index: UInt32
|
|
let focused: Int
|
|
let type: String
|
|
}
|
|
|
|
struct WindowInfo: Codable {
|
|
let id: UInt32
|
|
let space: UInt32
|
|
}
|