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:
Peter Steinberger 2025-07-01 19:16:25 +01:00
parent f1f99be514
commit f60bd2d5a0
7 changed files with 320 additions and 71 deletions

View file

@ -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
}

View file

@ -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)
)
}

View file

@ -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()

View file

@ -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) {

View file

@ -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)

View file

@ -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
}

View file

@ -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))