mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +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)
|
||||
case .iTerm2:
|
||||
focusiTerm2Window(windowInfo)
|
||||
case .ghostty:
|
||||
focusGhosttyWindow(windowInfo)
|
||||
default:
|
||||
// For other terminals, use standard window focus
|
||||
focusWindowUsingAccessibility(windowInfo)
|
||||
|
|
@ -549,17 +551,19 @@ final class WindowTracker {
|
|||
/// Focuses a Terminal.app window/tab.
|
||||
private func focusTerminalAppWindow(_ windowInfo: WindowInfo) {
|
||||
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 = """
|
||||
tell application "Terminal"
|
||||
activate
|
||||
\(tabRef)
|
||||
set selected of \(tabRef) to true
|
||||
set frontmost of window id \(windowInfo.windowID) to true
|
||||
end tell
|
||||
"""
|
||||
|
||||
do {
|
||||
try AppleScriptExecutor.shared.execute(script)
|
||||
logger.info("Focused Terminal.app tab using reference")
|
||||
logger.info("Focused Terminal.app tab using reference: \(tabRef)")
|
||||
} catch {
|
||||
logger.error("Failed to focus Terminal.app tab: \(error)")
|
||||
// Fallback to accessibility
|
||||
|
|
@ -593,6 +597,7 @@ final class WindowTracker {
|
|||
private func focusiTerm2Window(_ windowInfo: WindowInfo) {
|
||||
if let windowID = windowInfo.tabID {
|
||||
// Use window ID for focusing (stored in tabID for consistency)
|
||||
// iTerm2 uses 'select' to bring window to front
|
||||
let script = """
|
||||
tell application "iTerm2"
|
||||
activate
|
||||
|
|
@ -604,9 +609,10 @@ final class WindowTracker {
|
|||
|
||||
do {
|
||||
try AppleScriptExecutor.shared.execute(script)
|
||||
logger.info("Focused iTerm2 window using ID")
|
||||
logger.info("Focused iTerm2 window using ID: \(windowID)")
|
||||
} catch {
|
||||
logger.error("Failed to focus iTerm2 window: \(error)")
|
||||
// Fallback to accessibility
|
||||
focusWindowUsingAccessibility(windowInfo)
|
||||
}
|
||||
} 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.
|
||||
private func focusWindowUsingAccessibility(_ windowInfo: WindowInfo) {
|
||||
// First bring the application to front
|
||||
|
|
@ -647,6 +796,53 @@ final class WindowTracker {
|
|||
// Found the matching window, make it main and focused
|
||||
AXUIElementSetAttributeValue(window, kAXMainAttribute 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")
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ final class CustomMenuWindow: NSPanel {
|
|||
// Initialize window with appropriate style
|
||||
super.init(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 384, height: 400),
|
||||
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
|
||||
styleMask: [.borderless, .utilityWindow],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
|
@ -45,10 +45,14 @@ final class CustomMenuWindow: NSPanel {
|
|||
backgroundColor = .clear
|
||||
hasShadow = true
|
||||
level = .popUpMenu
|
||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
|
||||
isMovableByWindowBackground = false
|
||||
hidesOnDeactivate = false
|
||||
isReleasedWhenClosed = false
|
||||
|
||||
// Allow the window to become key but not main
|
||||
// This helps maintain button highlight state
|
||||
acceptsMouseMovedEvents = false
|
||||
|
||||
// Set content view controller
|
||||
contentViewController = hostingController
|
||||
|
|
@ -56,11 +60,27 @@ final class CustomMenuWindow: NSPanel {
|
|||
// Force the view to load immediately
|
||||
_ = hostingController.view
|
||||
|
||||
// Add visual effect background with rounded corners
|
||||
// Add visual effect background with custom shape
|
||||
if let contentView = contentViewController?.view {
|
||||
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
|
||||
contentView.shadow = NSShadow()
|
||||
|
|
@ -73,7 +93,7 @@ final class CustomMenuWindow: NSPanel {
|
|||
func show(relativeTo statusItemButton: NSStatusBarButton) {
|
||||
// Store button reference and ensure it stays highlighted
|
||||
self.statusBarButton = statusItemButton
|
||||
statusItemButton.state = .on
|
||||
statusItemButton.highlight(true)
|
||||
|
||||
// First, make sure the SwiftUI hierarchy has laid itself out
|
||||
hostingController.view.layoutSubtreeIfNeeded()
|
||||
|
|
@ -134,16 +154,17 @@ final class CustomMenuWindow: NSPanel {
|
|||
// Set all visual properties at once
|
||||
alphaValue = 1.0
|
||||
|
||||
// Ensure button state remains on
|
||||
statusBarButton?.state = .on
|
||||
// Ensure button remains highlighted
|
||||
statusBarButton?.highlight(true)
|
||||
|
||||
// Activate app and show window
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
makeKeyAndOrderFront(nil)
|
||||
// Show window without activating the app aggressively
|
||||
// This helps maintain the button's highlight state
|
||||
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
|
||||
self?.statusBarButton?.state = .on
|
||||
self?.statusBarButton?.highlight(true)
|
||||
}
|
||||
|
||||
// Set first responder after window is visible
|
||||
|
|
@ -238,8 +259,8 @@ final class CustomMenuWindow: NSPanel {
|
|||
// Mark window as not visible
|
||||
_isWindowVisible = false
|
||||
|
||||
// Reset button state when hiding
|
||||
statusBarButton?.state = .off
|
||||
// Reset button highlight when hiding
|
||||
statusBarButton?.highlight(false)
|
||||
orderOut(nil)
|
||||
teardownEventMonitoring()
|
||||
onHide?()
|
||||
|
|
@ -251,8 +272,8 @@ final class CustomMenuWindow: NSPanel {
|
|||
// Mark window as not visible
|
||||
_isWindowVisible = false
|
||||
|
||||
// Reset button state when window is ordered out
|
||||
statusBarButton?.state = .off
|
||||
// Reset button highlight when window is ordered out
|
||||
statusBarButton?.highlight(false)
|
||||
onHide?()
|
||||
}
|
||||
|
||||
|
|
@ -306,12 +327,67 @@ final class CustomMenuWindow: NSPanel {
|
|||
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.
|
||||
struct CustomMenuContainer<Content: View>: View {
|
||||
@ViewBuilder
|
||||
let content: Content
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
@Environment(\.colorScheme)
|
||||
private var colorScheme
|
||||
|
|
@ -319,9 +395,9 @@ struct CustomMenuContainer<Content: View>: View {
|
|||
var body: some View {
|
||||
content
|
||||
.fixedSize()
|
||||
.background(backgroundMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
SideRoundedRectangle(cornerRadius: 12)
|
||||
.stroke(borderColor, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ struct NewSessionForm: View {
|
|||
.foregroundColor(.white)
|
||||
.background(
|
||||
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)
|
||||
}
|
||||
|
|
@ -301,6 +301,7 @@ struct NewSessionForm: View {
|
|||
}
|
||||
.frame(width: 384)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.onAppear {
|
||||
loadPreferences()
|
||||
|
|
|
|||
|
|
@ -63,8 +63,10 @@ final class StatusBarController: NSObject {
|
|||
button.action = #selector(handleClick(_:))
|
||||
button.target = self
|
||||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||
|
||||
// Use pushOnPushOff for proper state management
|
||||
button.setButtonType(.pushOnPushOff)
|
||||
|
||||
|
||||
// Accessibility
|
||||
button.setAccessibilityTitle("VibeTunnel")
|
||||
button.setAccessibilityRole(.button)
|
||||
|
|
@ -126,9 +128,6 @@ final class StatusBarController: NSObject {
|
|||
func updateStatusItemDisplay() {
|
||||
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
|
||||
let iconName = (serverManager.isRunning && hasNetworkAccess) ? "menubar" : "menubar.inactive"
|
||||
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
|
||||
let sessions = sessionMonitor.sessions.values.filter(\.isRunning)
|
||||
let activeSessions = sessions.filter { session in
|
||||
|
|
@ -174,11 +166,6 @@ final class StatusBarController: NSObject {
|
|||
let indicator = formatSessionIndicator(activeCount: activeCount, totalCount: totalCount, style: indicatorStyle)
|
||||
button.title = indicator.isEmpty ? "" : " " + indicator
|
||||
|
||||
// Update button state after title change if needed
|
||||
if shouldBeHighlighted && button.state != .on {
|
||||
button.state = .on
|
||||
}
|
||||
|
||||
// Update tooltip
|
||||
updateTooltip()
|
||||
}
|
||||
|
|
@ -259,12 +246,6 @@ final class StatusBarController: NSObject {
|
|||
|
||||
private func handleLeftClick(_ button: NSStatusBarButton) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -69,13 +69,7 @@ final class StatusBarMenuManager: NSObject {
|
|||
statusBarButton = button
|
||||
}
|
||||
|
||||
// Update button state based on menu state (for .pushOnPushOff button type)
|
||||
switch menuState {
|
||||
case .none:
|
||||
statusBarButton?.state = .off
|
||||
case .customWindow, .contextMenu:
|
||||
statusBarButton?.state = .on
|
||||
}
|
||||
// No need to manage highlight here since we're using button state
|
||||
}
|
||||
|
||||
// MARK: - Left-Click Custom Window Management
|
||||
|
|
@ -83,8 +77,6 @@ final class StatusBarMenuManager: NSObject {
|
|||
func toggleCustomWindow(relativeTo button: NSStatusBarButton) {
|
||||
if let window = customWindow, window.isVisible {
|
||||
hideCustomWindow()
|
||||
// Ensure button state is updated
|
||||
button.state = .off
|
||||
} else {
|
||||
showCustomWindow(relativeTo: button)
|
||||
}
|
||||
|
|
@ -153,19 +145,19 @@ final class StatusBarMenuManager: NSObject {
|
|||
// Show the custom window
|
||||
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
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
button.state = .on
|
||||
button.highlight(true)
|
||||
}
|
||||
}
|
||||
|
||||
func hideCustomWindow() {
|
||||
customWindow?.hide()
|
||||
// Note: state will be reset by the onHide callback
|
||||
// But also ensure button state is updated immediately
|
||||
// Reset button state
|
||||
statusBarButton?.state = .off
|
||||
// Note: state will be reset by the onHide callback
|
||||
}
|
||||
|
||||
var isCustomWindowVisible: Bool {
|
||||
|
|
@ -195,11 +187,14 @@ final class StatusBarMenuManager: NSObject {
|
|||
// Hide custom window first if it's visible
|
||||
hideCustomWindow()
|
||||
|
||||
// Update menu state to context menu
|
||||
updateMenuState(.contextMenu, button: button)
|
||||
|
||||
// Store status item reference
|
||||
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()
|
||||
menu.delegate = self
|
||||
|
|
@ -282,9 +277,6 @@ final class StatusBarMenuManager: NSObject {
|
|||
// Show the context menu
|
||||
// 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)
|
||||
|
||||
// Update state to indicate no menu is active after context menu closes
|
||||
updateMenuState(.none, button: button)
|
||||
}
|
||||
|
||||
// MARK: - Context Menu Actions
|
||||
|
|
@ -362,6 +354,9 @@ final class StatusBarMenuManager: NSObject {
|
|||
|
||||
extension StatusBarMenuManager: NSMenuDelegate {
|
||||
func menuDidClose(_ menu: NSMenu) {
|
||||
// Reset button state
|
||||
statusBarButton?.state = .off
|
||||
|
||||
// Reset menu state when context menu closes
|
||||
updateMenuState(.none)
|
||||
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ struct SessionRow: View {
|
|||
HStack(spacing: 4) {
|
||||
Text(activityStatus)
|
||||
.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)
|
||||
|
||||
|
|
@ -548,7 +548,7 @@ struct SessionRow: View {
|
|||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(width: 35, alignment: .trailing)
|
||||
.frame(width: 40, alignment: .trailing)
|
||||
.animation(.easeInOut(duration: 0.15), value: isHovered)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
|
|
@ -704,7 +704,7 @@ struct SessionRow: View {
|
|||
|
||||
private var activityColor: Color {
|
||||
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 {
|
||||
Color(red: 0.0, green: 0.7, blue: 0.0) // Darker, more visible green
|
||||
}
|
||||
|
|
|
|||
|
|
@ -367,7 +367,7 @@ struct SessionRowView: View {
|
|||
if let activityStatus {
|
||||
Text(activityStatus)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.orange)
|
||||
.foregroundColor(Color(red: 0.8, green: 0.4, blue: 0.0))
|
||||
|
||||
Text("·")
|
||||
.font(.system(size: 11))
|
||||
|
|
|
|||
Loading…
Reference in a new issue