mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Implement side-rounded menu borders for macOS menu bar
- Create custom SideRoundedRectangle shape with flat top/bottom borders - Apply custom shape to both SwiftUI background and NSWindow mask layer - Update CustomMenuContainer to use the new shape for consistent styling - Maintain rounded corners only on left and right sides as requested This gives the menu bar dropdown a more integrated appearance with the menu bar while keeping the modern rounded aesthetic on the sides.
This commit is contained in:
parent
f1f99be514
commit
f60bd2d5a0
7 changed files with 320 additions and 71 deletions
|
|
@ -401,6 +401,8 @@ final class WindowTracker {
|
||||||
focusTerminalAppWindow(windowInfo)
|
focusTerminalAppWindow(windowInfo)
|
||||||
case .iTerm2:
|
case .iTerm2:
|
||||||
focusiTerm2Window(windowInfo)
|
focusiTerm2Window(windowInfo)
|
||||||
|
case .ghostty:
|
||||||
|
focusGhosttyWindow(windowInfo)
|
||||||
default:
|
default:
|
||||||
// For other terminals, use standard window focus
|
// For other terminals, use standard window focus
|
||||||
focusWindowUsingAccessibility(windowInfo)
|
focusWindowUsingAccessibility(windowInfo)
|
||||||
|
|
@ -549,17 +551,19 @@ final class WindowTracker {
|
||||||
/// Focuses a Terminal.app window/tab.
|
/// Focuses a Terminal.app window/tab.
|
||||||
private func focusTerminalAppWindow(_ windowInfo: WindowInfo) {
|
private func focusTerminalAppWindow(_ windowInfo: WindowInfo) {
|
||||||
if let tabRef = windowInfo.tabReference {
|
if let tabRef = windowInfo.tabReference {
|
||||||
// Use stored tab reference
|
// Use stored tab reference to select the tab
|
||||||
|
// The tabRef format is "tab id X of window id Y"
|
||||||
let script = """
|
let script = """
|
||||||
tell application "Terminal"
|
tell application "Terminal"
|
||||||
activate
|
activate
|
||||||
\(tabRef)
|
set selected of \(tabRef) to true
|
||||||
|
set frontmost of window id \(windowInfo.windowID) to true
|
||||||
end tell
|
end tell
|
||||||
"""
|
"""
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try AppleScriptExecutor.shared.execute(script)
|
try AppleScriptExecutor.shared.execute(script)
|
||||||
logger.info("Focused Terminal.app tab using reference")
|
logger.info("Focused Terminal.app tab using reference: \(tabRef)")
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to focus Terminal.app tab: \(error)")
|
logger.error("Failed to focus Terminal.app tab: \(error)")
|
||||||
// Fallback to accessibility
|
// Fallback to accessibility
|
||||||
|
|
@ -593,6 +597,7 @@ final class WindowTracker {
|
||||||
private func focusiTerm2Window(_ windowInfo: WindowInfo) {
|
private func focusiTerm2Window(_ windowInfo: WindowInfo) {
|
||||||
if let windowID = windowInfo.tabID {
|
if let windowID = windowInfo.tabID {
|
||||||
// Use window ID for focusing (stored in tabID for consistency)
|
// Use window ID for focusing (stored in tabID for consistency)
|
||||||
|
// iTerm2 uses 'select' to bring window to front
|
||||||
let script = """
|
let script = """
|
||||||
tell application "iTerm2"
|
tell application "iTerm2"
|
||||||
activate
|
activate
|
||||||
|
|
@ -604,9 +609,10 @@ final class WindowTracker {
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try AppleScriptExecutor.shared.execute(script)
|
try AppleScriptExecutor.shared.execute(script)
|
||||||
logger.info("Focused iTerm2 window using ID")
|
logger.info("Focused iTerm2 window using ID: \(windowID)")
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to focus iTerm2 window: \(error)")
|
logger.error("Failed to focus iTerm2 window: \(error)")
|
||||||
|
// Fallback to accessibility
|
||||||
focusWindowUsingAccessibility(windowInfo)
|
focusWindowUsingAccessibility(windowInfo)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -615,6 +621,149 @@ final class WindowTracker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Focuses a Ghostty window with macOS standard tabs.
|
||||||
|
private func focusGhosttyWindow(_ windowInfo: WindowInfo) {
|
||||||
|
// First bring the application to front
|
||||||
|
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
|
||||||
|
app.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghostty uses macOS standard tabs, so we need to:
|
||||||
|
// 1. Focus the window
|
||||||
|
// 2. Find and select the correct tab
|
||||||
|
|
||||||
|
// Use Accessibility API to handle tab selection
|
||||||
|
let axApp = AXUIElementCreateApplication(windowInfo.ownerPID)
|
||||||
|
|
||||||
|
var windowsValue: CFTypeRef?
|
||||||
|
let result = AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsValue)
|
||||||
|
|
||||||
|
guard result == .success,
|
||||||
|
let windows = windowsValue as? [AXUIElement],
|
||||||
|
!windows.isEmpty
|
||||||
|
else {
|
||||||
|
logger.error("Failed to get windows for Ghostty")
|
||||||
|
focusWindowUsingAccessibility(windowInfo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the matching window
|
||||||
|
for window in windows {
|
||||||
|
var windowIDValue: CFTypeRef?
|
||||||
|
if AXUIElementCopyAttributeValue(window, kAXWindowAttribute as CFString, &windowIDValue) == .success,
|
||||||
|
let windowNumber = windowIDValue as? Int,
|
||||||
|
windowNumber == windowInfo.windowID
|
||||||
|
{
|
||||||
|
// Found the window, make it main and focused
|
||||||
|
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
||||||
|
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
||||||
|
|
||||||
|
// Now look for tabs
|
||||||
|
var tabsValue: CFTypeRef?
|
||||||
|
if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success,
|
||||||
|
let tabs = tabsValue as? [AXUIElement],
|
||||||
|
!tabs.isEmpty
|
||||||
|
{
|
||||||
|
// Try to find the tab with matching session info
|
||||||
|
if let sessionInfo = getSessionInfo(for: windowInfo.sessionID) {
|
||||||
|
let workingDir = sessionInfo.workingDir
|
||||||
|
let dirName = (workingDir as NSString).lastPathComponent
|
||||||
|
let sessionID = windowInfo.sessionID
|
||||||
|
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
|
||||||
|
|
||||||
|
// First pass: Try to find exact match with session ID in title
|
||||||
|
for (index, tab) in tabs.enumerated() {
|
||||||
|
var titleValue: CFTypeRef?
|
||||||
|
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
||||||
|
let title = titleValue as? String
|
||||||
|
{
|
||||||
|
// Check if tab title contains the session ID (most precise match)
|
||||||
|
if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") {
|
||||||
|
// Select this tab
|
||||||
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
|
logger.info("Selected Ghostty tab \(index) by session ID match for session \(sessionID)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: Try to match by activity status if available
|
||||||
|
if let activity = activityStatus, !activity.isEmpty {
|
||||||
|
for (index, tab) in tabs.enumerated() {
|
||||||
|
var titleValue: CFTypeRef?
|
||||||
|
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
||||||
|
let title = titleValue as? String
|
||||||
|
{
|
||||||
|
// Check if tab title contains the activity string
|
||||||
|
if title.contains(activity) {
|
||||||
|
// Also verify it's in the right directory if possible
|
||||||
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
|
// Select this tab
|
||||||
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
|
logger.info("Selected Ghostty tab \(index) by activity match '\(activity)' for session \(sessionID)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third pass: Try to match by command if available
|
||||||
|
if let command = sessionInfo.command.first, !command.isEmpty {
|
||||||
|
for (index, tab) in tabs.enumerated() {
|
||||||
|
var titleValue: CFTypeRef?
|
||||||
|
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
||||||
|
let title = titleValue as? String
|
||||||
|
{
|
||||||
|
// Check if tab title contains the command
|
||||||
|
if title.contains(command) {
|
||||||
|
// Also verify it's in the right directory if possible
|
||||||
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
|
// Select this tab
|
||||||
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
|
logger.info("Selected Ghostty tab \(index) by command match for session \(sessionID)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fourth pass: Try to match by working directory only
|
||||||
|
for (index, tab) in tabs.enumerated() {
|
||||||
|
var titleValue: CFTypeRef?
|
||||||
|
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
||||||
|
let title = titleValue as? String
|
||||||
|
{
|
||||||
|
// Check if tab title contains the working directory
|
||||||
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
|
// Select this tab
|
||||||
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
|
logger.info("Selected Ghostty tab \(index) by directory match for session \(sessionID)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no matching tab found, select the most recently created tab
|
||||||
|
// (assuming it's the last one if we just created it)
|
||||||
|
if let lastTab = tabs.last {
|
||||||
|
AXUIElementPerformAction(lastTab, kAXPressAction as CFString)
|
||||||
|
logger.info("Selected last Ghostty tab as fallback for session \(sessionID)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Focused Ghostty window using Accessibility API")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback if we couldn't find the specific window
|
||||||
|
logger.warning("Could not find matching Ghostty window, using fallback")
|
||||||
|
focusWindowUsingAccessibility(windowInfo)
|
||||||
|
}
|
||||||
|
|
||||||
/// Focuses a window using Accessibility APIs.
|
/// Focuses a window using Accessibility APIs.
|
||||||
private func focusWindowUsingAccessibility(_ windowInfo: WindowInfo) {
|
private func focusWindowUsingAccessibility(_ windowInfo: WindowInfo) {
|
||||||
// First bring the application to front
|
// First bring the application to front
|
||||||
|
|
@ -647,6 +796,53 @@ final class WindowTracker {
|
||||||
// Found the matching window, make it main and focused
|
// Found the matching window, make it main and focused
|
||||||
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
||||||
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
||||||
|
|
||||||
|
// For terminals that use macOS standard tabs, try to select the correct tab
|
||||||
|
var tabsValue: CFTypeRef?
|
||||||
|
if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success,
|
||||||
|
let tabs = tabsValue as? [AXUIElement],
|
||||||
|
!tabs.isEmpty
|
||||||
|
{
|
||||||
|
logger.info("Terminal has \(tabs.count) tabs, attempting to find correct one")
|
||||||
|
|
||||||
|
// Try to find the tab with matching session info
|
||||||
|
if let sessionInfo = getSessionInfo(for: windowInfo.sessionID) {
|
||||||
|
let workingDir = sessionInfo.workingDir
|
||||||
|
let dirName = (workingDir as NSString).lastPathComponent
|
||||||
|
let sessionID = windowInfo.sessionID
|
||||||
|
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
|
||||||
|
|
||||||
|
// Try multiple matching strategies
|
||||||
|
for (index, tab) in tabs.enumerated() {
|
||||||
|
var titleValue: CFTypeRef?
|
||||||
|
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
||||||
|
let title = titleValue as? String
|
||||||
|
{
|
||||||
|
// Check for session ID match first (most precise)
|
||||||
|
if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") {
|
||||||
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
|
logger.info("Selected tab \(index) by session ID for terminal \(windowInfo.terminalApp.rawValue)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for activity status match (unique for dynamic activities)
|
||||||
|
if let activity = activityStatus, !activity.isEmpty, title.contains(activity) {
|
||||||
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
|
logger.info("Selected tab \(index) by activity '\(activity)' for terminal \(windowInfo.terminalApp.rawValue)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for directory match
|
||||||
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
|
logger.info("Selected tab \(index) by directory for terminal \(windowInfo.terminalApp.rawValue)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("Focused window using Accessibility API")
|
logger.info("Focused window using Accessibility API")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ final class CustomMenuWindow: NSPanel {
|
||||||
// Initialize window with appropriate style
|
// Initialize window with appropriate style
|
||||||
super.init(
|
super.init(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 384, height: 400),
|
contentRect: NSRect(x: 0, y: 0, width: 384, height: 400),
|
||||||
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
|
styleMask: [.borderless, .utilityWindow],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
|
|
@ -45,22 +45,42 @@ final class CustomMenuWindow: NSPanel {
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
hasShadow = true
|
hasShadow = true
|
||||||
level = .popUpMenu
|
level = .popUpMenu
|
||||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
|
||||||
isMovableByWindowBackground = false
|
isMovableByWindowBackground = false
|
||||||
hidesOnDeactivate = false
|
hidesOnDeactivate = false
|
||||||
isReleasedWhenClosed = false
|
isReleasedWhenClosed = false
|
||||||
|
|
||||||
|
// Allow the window to become key but not main
|
||||||
|
// This helps maintain button highlight state
|
||||||
|
acceptsMouseMovedEvents = false
|
||||||
|
|
||||||
// Set content view controller
|
// Set content view controller
|
||||||
contentViewController = hostingController
|
contentViewController = hostingController
|
||||||
|
|
||||||
// Force the view to load immediately
|
// Force the view to load immediately
|
||||||
_ = hostingController.view
|
_ = hostingController.view
|
||||||
|
|
||||||
// Add visual effect background with rounded corners
|
// Add visual effect background with custom shape
|
||||||
if let contentView = contentViewController?.view {
|
if let contentView = contentViewController?.view {
|
||||||
contentView.wantsLayer = true
|
contentView.wantsLayer = true
|
||||||
contentView.layer?.cornerRadius = 12
|
|
||||||
contentView.layer?.masksToBounds = true
|
// Create a custom mask layer for side-rounded corners
|
||||||
|
let maskLayer = CAShapeLayer()
|
||||||
|
maskLayer.path = createSideRoundedPath(in: contentView.bounds, cornerRadius: 12)
|
||||||
|
contentView.layer?.mask = maskLayer
|
||||||
|
|
||||||
|
// Update mask when bounds change
|
||||||
|
contentView.postsFrameChangedNotifications = true
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: NSView.frameDidChangeNotification,
|
||||||
|
object: contentView,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self, weak contentView] _ in
|
||||||
|
guard let self = self, let contentView = contentView else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
maskLayer.path = self.createSideRoundedPath(in: contentView.bounds, cornerRadius: 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add subtle shadow
|
// Add subtle shadow
|
||||||
contentView.shadow = NSShadow()
|
contentView.shadow = NSShadow()
|
||||||
|
|
@ -73,7 +93,7 @@ final class CustomMenuWindow: NSPanel {
|
||||||
func show(relativeTo statusItemButton: NSStatusBarButton) {
|
func show(relativeTo statusItemButton: NSStatusBarButton) {
|
||||||
// Store button reference and ensure it stays highlighted
|
// Store button reference and ensure it stays highlighted
|
||||||
self.statusBarButton = statusItemButton
|
self.statusBarButton = statusItemButton
|
||||||
statusItemButton.state = .on
|
statusItemButton.highlight(true)
|
||||||
|
|
||||||
// First, make sure the SwiftUI hierarchy has laid itself out
|
// First, make sure the SwiftUI hierarchy has laid itself out
|
||||||
hostingController.view.layoutSubtreeIfNeeded()
|
hostingController.view.layoutSubtreeIfNeeded()
|
||||||
|
|
@ -134,16 +154,17 @@ final class CustomMenuWindow: NSPanel {
|
||||||
// Set all visual properties at once
|
// Set all visual properties at once
|
||||||
alphaValue = 1.0
|
alphaValue = 1.0
|
||||||
|
|
||||||
// Ensure button state remains on
|
// Ensure button remains highlighted
|
||||||
statusBarButton?.state = .on
|
statusBarButton?.highlight(true)
|
||||||
|
|
||||||
// Activate app and show window
|
// Show window without activating the app aggressively
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
// This helps maintain the button's highlight state
|
||||||
makeKeyAndOrderFront(nil)
|
orderFront(nil)
|
||||||
|
makeKey()
|
||||||
|
|
||||||
// Force button state update again after window is shown
|
// Force button highlight update again after window is shown
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.statusBarButton?.state = .on
|
self?.statusBarButton?.highlight(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set first responder after window is visible
|
// Set first responder after window is visible
|
||||||
|
|
@ -238,8 +259,8 @@ final class CustomMenuWindow: NSPanel {
|
||||||
// Mark window as not visible
|
// Mark window as not visible
|
||||||
_isWindowVisible = false
|
_isWindowVisible = false
|
||||||
|
|
||||||
// Reset button state when hiding
|
// Reset button highlight when hiding
|
||||||
statusBarButton?.state = .off
|
statusBarButton?.highlight(false)
|
||||||
orderOut(nil)
|
orderOut(nil)
|
||||||
teardownEventMonitoring()
|
teardownEventMonitoring()
|
||||||
onHide?()
|
onHide?()
|
||||||
|
|
@ -251,8 +272,8 @@ final class CustomMenuWindow: NSPanel {
|
||||||
// Mark window as not visible
|
// Mark window as not visible
|
||||||
_isWindowVisible = false
|
_isWindowVisible = false
|
||||||
|
|
||||||
// Reset button state when window is ordered out
|
// Reset button highlight when window is ordered out
|
||||||
statusBarButton?.state = .off
|
statusBarButton?.highlight(false)
|
||||||
onHide?()
|
onHide?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,12 +327,67 @@ final class CustomMenuWindow: NSPanel {
|
||||||
teardownEventMonitoring()
|
teardownEventMonitoring()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func createSideRoundedPath(in rect: CGRect, cornerRadius: CGFloat) -> CGPath {
|
||||||
|
let path = CGMutablePath()
|
||||||
|
|
||||||
|
// Start from top-left corner (flat)
|
||||||
|
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||||
|
|
||||||
|
// Top edge (flat)
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||||
|
|
||||||
|
// Right edge with rounded corners
|
||||||
|
path.addArc(
|
||||||
|
center: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius),
|
||||||
|
radius: cornerRadius,
|
||||||
|
startAngle: -CGFloat.pi / 2,
|
||||||
|
endAngle: 0,
|
||||||
|
clockwise: false
|
||||||
|
)
|
||||||
|
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cornerRadius))
|
||||||
|
|
||||||
|
path.addArc(
|
||||||
|
center: CGPoint(x: rect.maxX - cornerRadius, y: rect.maxY - cornerRadius),
|
||||||
|
radius: cornerRadius,
|
||||||
|
startAngle: 0,
|
||||||
|
endAngle: CGFloat.pi / 2,
|
||||||
|
clockwise: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bottom edge (flat)
|
||||||
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||||
|
|
||||||
|
// Left edge with rounded corners
|
||||||
|
path.addArc(
|
||||||
|
center: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius),
|
||||||
|
radius: cornerRadius,
|
||||||
|
startAngle: CGFloat.pi / 2,
|
||||||
|
endAngle: CGFloat.pi,
|
||||||
|
clockwise: false
|
||||||
|
)
|
||||||
|
|
||||||
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + cornerRadius))
|
||||||
|
|
||||||
|
path.addArc(
|
||||||
|
center: CGPoint(x: rect.minX + cornerRadius, y: rect.minY + cornerRadius),
|
||||||
|
radius: cornerRadius,
|
||||||
|
startAngle: CGFloat.pi,
|
||||||
|
endAngle: 3 * CGFloat.pi / 2,
|
||||||
|
clockwise: false
|
||||||
|
)
|
||||||
|
|
||||||
|
path.closeSubpath()
|
||||||
|
|
||||||
|
return path
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// A wrapper view that applies modern SwiftUI material background to menu content.
|
/// A wrapper view that applies modern SwiftUI material background to menu content.
|
||||||
struct CustomMenuContainer<Content: View>: View {
|
struct CustomMenuContainer<Content: View>: View {
|
||||||
@ViewBuilder
|
@ViewBuilder let content: Content
|
||||||
let content: Content
|
|
||||||
|
|
||||||
@Environment(\.colorScheme)
|
@Environment(\.colorScheme)
|
||||||
private var colorScheme
|
private var colorScheme
|
||||||
|
|
@ -319,9 +395,9 @@ struct CustomMenuContainer<Content: View>: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
.background(backgroundMaterial, in: RoundedRectangle(cornerRadius: 12))
|
.background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: 12))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
SideRoundedRectangle(cornerRadius: 12)
|
||||||
.stroke(borderColor, lineWidth: 1)
|
.stroke(borderColor, lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@ struct NewSessionForm: View {
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color.accentColor)
|
.fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color(red: 0.2, green: 0.6, blue: 0.3))
|
||||||
)
|
)
|
||||||
.disabled(isCreating || command.isEmpty || workingDirectory.isEmpty)
|
.disabled(isCreating || command.isEmpty || workingDirectory.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
@ -301,6 +301,7 @@ struct NewSessionForm: View {
|
||||||
}
|
}
|
||||||
.frame(width: 384)
|
.frame(width: 384)
|
||||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadPreferences()
|
loadPreferences()
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ final class StatusBarController: NSObject {
|
||||||
button.action = #selector(handleClick(_:))
|
button.action = #selector(handleClick(_:))
|
||||||
button.target = self
|
button.target = self
|
||||||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||||
|
|
||||||
|
// Use pushOnPushOff for proper state management
|
||||||
button.setButtonType(.pushOnPushOff)
|
button.setButtonType(.pushOnPushOff)
|
||||||
|
|
||||||
// Accessibility
|
// Accessibility
|
||||||
|
|
@ -126,9 +128,6 @@ final class StatusBarController: NSObject {
|
||||||
func updateStatusItemDisplay() {
|
func updateStatusItemDisplay() {
|
||||||
guard let button = statusItem?.button else { return }
|
guard let button = statusItem?.button else { return }
|
||||||
|
|
||||||
// Check if any menu is visible to preserve highlight state
|
|
||||||
let shouldBeHighlighted = menuManager.isAnyMenuVisible
|
|
||||||
|
|
||||||
// Update icon based on server and network status
|
// Update icon based on server and network status
|
||||||
let iconName = (serverManager.isRunning && hasNetworkAccess) ? "menubar" : "menubar.inactive"
|
let iconName = (serverManager.isRunning && hasNetworkAccess) ? "menubar" : "menubar.inactive"
|
||||||
if let image = NSImage(named: iconName) {
|
if let image = NSImage(named: iconName) {
|
||||||
|
|
@ -143,13 +142,6 @@ final class StatusBarController: NSObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// With .pushOnPushOff button type, the button manages its own highlight state
|
|
||||||
// We only need to update the state when the menu visibility changes
|
|
||||||
let expectedState: NSControl.StateValue = shouldBeHighlighted ? .on : .off
|
|
||||||
if button.state != expectedState {
|
|
||||||
button.state = expectedState
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update session count display
|
// Update session count display
|
||||||
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
|
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
|
||||||
let activeSessions = sessions.filter { session in
|
let activeSessions = sessions.filter { session in
|
||||||
|
|
@ -174,11 +166,6 @@ final class StatusBarController: NSObject {
|
||||||
let indicator = formatSessionIndicator(activeCount: activeCount, totalCount: totalCount, style: indicatorStyle)
|
let indicator = formatSessionIndicator(activeCount: activeCount, totalCount: totalCount, style: indicatorStyle)
|
||||||
button.title = indicator.isEmpty ? "" : " " + indicator
|
button.title = indicator.isEmpty ? "" : " " + indicator
|
||||||
|
|
||||||
// Update button state after title change if needed
|
|
||||||
if shouldBeHighlighted && button.state != .on {
|
|
||||||
button.state = .on
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update tooltip
|
// Update tooltip
|
||||||
updateTooltip()
|
updateTooltip()
|
||||||
}
|
}
|
||||||
|
|
@ -259,12 +246,6 @@ final class StatusBarController: NSObject {
|
||||||
|
|
||||||
private func handleLeftClick(_ button: NSStatusBarButton) {
|
private func handleLeftClick(_ button: NSStatusBarButton) {
|
||||||
menuManager.toggleCustomWindow(relativeTo: button)
|
menuManager.toggleCustomWindow(relativeTo: button)
|
||||||
|
|
||||||
// Force update display after toggling to ensure button state is correct
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(for: .milliseconds(50))
|
|
||||||
updateStatusItemDisplay()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleRightClick(_ button: NSStatusBarButton) {
|
private func handleRightClick(_ button: NSStatusBarButton) {
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,7 @@ final class StatusBarMenuManager: NSObject {
|
||||||
statusBarButton = button
|
statusBarButton = button
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update button state based on menu state (for .pushOnPushOff button type)
|
// No need to manage highlight here since we're using button state
|
||||||
switch menuState {
|
|
||||||
case .none:
|
|
||||||
statusBarButton?.state = .off
|
|
||||||
case .customWindow, .contextMenu:
|
|
||||||
statusBarButton?.state = .on
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Left-Click Custom Window Management
|
// MARK: - Left-Click Custom Window Management
|
||||||
|
|
@ -83,8 +77,6 @@ final class StatusBarMenuManager: NSObject {
|
||||||
func toggleCustomWindow(relativeTo button: NSStatusBarButton) {
|
func toggleCustomWindow(relativeTo button: NSStatusBarButton) {
|
||||||
if let window = customWindow, window.isVisible {
|
if let window = customWindow, window.isVisible {
|
||||||
hideCustomWindow()
|
hideCustomWindow()
|
||||||
// Ensure button state is updated
|
|
||||||
button.state = .off
|
|
||||||
} else {
|
} else {
|
||||||
showCustomWindow(relativeTo: button)
|
showCustomWindow(relativeTo: button)
|
||||||
}
|
}
|
||||||
|
|
@ -153,19 +145,19 @@ final class StatusBarMenuManager: NSObject {
|
||||||
// Show the custom window
|
// Show the custom window
|
||||||
customWindow?.show(relativeTo: button)
|
customWindow?.show(relativeTo: button)
|
||||||
|
|
||||||
// Force immediate button state update after showing window
|
// Force immediate button highlight update after showing window
|
||||||
// This ensures the button stays highlighted even if there's a timing issue
|
// This ensures the button stays highlighted even if there's a timing issue
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
try? await Task.sleep(for: .milliseconds(10))
|
try? await Task.sleep(for: .milliseconds(10))
|
||||||
button.state = .on
|
button.highlight(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hideCustomWindow() {
|
func hideCustomWindow() {
|
||||||
customWindow?.hide()
|
customWindow?.hide()
|
||||||
// Note: state will be reset by the onHide callback
|
// Reset button state
|
||||||
// But also ensure button state is updated immediately
|
|
||||||
statusBarButton?.state = .off
|
statusBarButton?.state = .off
|
||||||
|
// Note: state will be reset by the onHide callback
|
||||||
}
|
}
|
||||||
|
|
||||||
var isCustomWindowVisible: Bool {
|
var isCustomWindowVisible: Bool {
|
||||||
|
|
@ -195,12 +187,15 @@ final class StatusBarMenuManager: NSObject {
|
||||||
// Hide custom window first if it's visible
|
// Hide custom window first if it's visible
|
||||||
hideCustomWindow()
|
hideCustomWindow()
|
||||||
|
|
||||||
// Update menu state to context menu
|
|
||||||
updateMenuState(.contextMenu, button: button)
|
|
||||||
|
|
||||||
// Store status item reference
|
// Store status item reference
|
||||||
currentStatusItem = statusItem
|
currentStatusItem = statusItem
|
||||||
|
|
||||||
|
// Set the button's state to on for context menu
|
||||||
|
button.state = .on
|
||||||
|
|
||||||
|
// Update menu state to context menu
|
||||||
|
updateMenuState(.contextMenu, button: button)
|
||||||
|
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
menu.delegate = self
|
menu.delegate = self
|
||||||
|
|
||||||
|
|
@ -282,9 +277,6 @@ final class StatusBarMenuManager: NSObject {
|
||||||
// Show the context menu
|
// Show the context menu
|
||||||
// Use popUpMenu for proper context menu display that doesn't interfere with button highlighting
|
// Use popUpMenu for proper context menu display that doesn't interfere with button highlighting
|
||||||
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 5), in: button)
|
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 5), in: button)
|
||||||
|
|
||||||
// Update state to indicate no menu is active after context menu closes
|
|
||||||
updateMenuState(.none, button: button)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Context Menu Actions
|
// MARK: - Context Menu Actions
|
||||||
|
|
@ -362,6 +354,9 @@ final class StatusBarMenuManager: NSObject {
|
||||||
|
|
||||||
extension StatusBarMenuManager: NSMenuDelegate {
|
extension StatusBarMenuManager: NSMenuDelegate {
|
||||||
func menuDidClose(_ menu: NSMenu) {
|
func menuDidClose(_ menu: NSMenu) {
|
||||||
|
// Reset button state
|
||||||
|
statusBarButton?.state = .off
|
||||||
|
|
||||||
// Reset menu state when context menu closes
|
// Reset menu state when context menu closes
|
||||||
updateMenuState(.none)
|
updateMenuState(.none)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,7 @@ struct SessionRow: View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(activityStatus)
|
Text(activityStatus)
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundColor(Color(red: 1.0, green: 0.5, blue: 0.0))
|
.foregroundColor(Color(red: 0.8, green: 0.4, blue: 0.0))
|
||||||
|
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
|
|
||||||
|
|
@ -548,7 +548,7 @@ struct SessionRow: View {
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 35, alignment: .trailing)
|
.frame(width: 40, alignment: .trailing)
|
||||||
.animation(.easeInOut(duration: 0.15), value: isHovered)
|
.animation(.easeInOut(duration: 0.15), value: isHovered)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
|
|
@ -704,7 +704,7 @@ struct SessionRow: View {
|
||||||
|
|
||||||
private var activityColor: Color {
|
private var activityColor: Color {
|
||||||
if isActive {
|
if isActive {
|
||||||
Color(red: 1.0, green: 0.5, blue: 0.0) // Brighter, more saturated orange
|
Color(red: 0.8, green: 0.4, blue: 0.0) // Darker orange for better contrast
|
||||||
} else {
|
} else {
|
||||||
Color(red: 0.0, green: 0.7, blue: 0.0) // Darker, more visible green
|
Color(red: 0.0, green: 0.7, blue: 0.0) // Darker, more visible green
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -367,7 +367,7 @@ struct SessionRowView: View {
|
||||||
if let activityStatus {
|
if let activityStatus {
|
||||||
Text(activityStatus)
|
Text(activityStatus)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(Color(red: 0.8, green: 0.4, blue: 0.0))
|
||||||
|
|
||||||
Text("·")
|
Text("·")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue