beatmatchr/Desktop/YabaiPro/Sources/StatusBarController.swift
BusyBee3333 7694d965c9 feat: Add structured signal editor with app dropdown and action builder
- 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
2025-12-31 01:44:13 -05:00

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
}