Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion AudioPriorityBar/Views/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import AppKit

struct MenuBarView: View {
@EnvironmentObject var audioManager: AudioManager
var availableHeight: CGFloat? = nil
@State private var screenHeight = NSScreen.main?.visibleFrame.height ?? 600

var body: some View {
VStack(spacing: 0) {
Expand Down Expand Up @@ -82,7 +84,8 @@ struct MenuBarView: View {
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
.frame(maxHeight: 420)
// Reserve space for the fixed header/footer and the screen edge.
.frame(height: max(1, min(420, (availableHeight ?? screenHeight) - 160)))

Divider()
.padding(.horizontal, 12)
Expand Down Expand Up @@ -133,6 +136,48 @@ struct MenuBarView: View {
.animation(.easeInOut(duration: 0.2), value: audioManager.isEditMode)
}
.frame(width: 340)
.background(PopoverScreenHeight { screenHeight = $0 })
}
}

// Use the popover's own display, including when it is on a secondary screen.
private struct PopoverScreenHeight: NSViewRepresentable {
var onChange: (CGFloat) -> Void

func makeNSView(context: Context) -> ScreenView {
let view = ScreenView()
view.onChange = onChange
return view
}

func updateNSView(_ view: ScreenView, context: Context) {
view.onChange = onChange
}

final class ScreenView: NSView {
var onChange: ((CGFloat) -> Void)?
private var observers: [NSObjectProtocol] = []

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
observers.forEach(NotificationCenter.default.removeObserver)
observers = []
guard let window else { return }
for name in [NSWindow.didChangeScreenNotification, NSApplication.didChangeScreenParametersNotification] {
observers.append(NotificationCenter.default.addObserver(forName: name,
object: name == NSWindow.didChangeScreenNotification ? window : nil, queue: .main) { [weak self] _ in
self?.reportHeight()
})
}
reportHeight()
}

private func reportHeight() {
guard let height = window?.screen?.visibleFrame.height else { return }
DispatchQueue.main.async { [weak self] in self?.onChange?(height) }
}

deinit { observers.forEach(NotificationCenter.default.removeObserver) }
}
}

Expand Down
31 changes: 31 additions & 0 deletions Tests/Popover-UAT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Popover layout regression checks

Build with `./build.sh`. Run the target app in Custom mode so opening the menu does not change the selected audio devices. With macOS Accessibility access for the terminal, run:

```sh
osascript Tests/popover-uat.applescript AudioPriorityBar "MacBook Air Microphone"
```

Replace the process name and optional device name for the test machine. The script checks a normal-display list viewport, checks the named rows, closes the panel, then opens and checks it again. The original layout fails with height 0 on affected macOS versions.

## Constrained height

```sh
sh Tests/run-popover-layout-tests.sh
```

This compiles the real views and manager with a separate test entry point and a unique preference domain. It enables the existing Custom mode before manager construction and replaces only the displayed rows with fixtures. It does not select or change system audio devices.

For 3 and 20 input rows, the test supplies display height limits of 553, 400, and 300 points. It checks that both the native panel and its fitting height stay within that limit without collapsing. The previous fixed 420-point viewport produces a 553-point panel and fails the 400-point case.

To inspect the final 300-point case, including the footer and scrolling:

```sh
POPOVER_LAYOUT_HOLD=1 sh Tests/run-popover-layout-tests.sh
```

While the fixture is open, `osascript Tests/popover-uat.applescript PopoverLayoutTests --window-only` checks its viewport and that all top-level buttons lie inside the native panel bounds.

The command prints the temporary app path and leaves the fixture visible for two minutes. Check that Login, Edit, and Quit remain visible and that scrolling reaches Test microphone 20. This is a constrained-window test, not a physical display-resolution change.

The app takes the visible height of the popover's own screen and reserves 160 points for its fixed header/footer and edge clearance. It caps the remaining list area at 420 points. The screen observer updates the budget when display parameters or the window's screen change. Check opening on each display after changing scaling or Dock placement when those configurations are available.
60 changes: 60 additions & 0 deletions Tests/PopoverLayoutTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import AppKit
import SwiftUI

@main
@MainActor
struct PopoverLayoutTests {
static func main() {
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.finishLaunching()
guard let domain = Bundle.main.bundleIdentifier,
domain.hasPrefix("app.audioprioritybar.layouttests.") else {
fatalError("Run through run-popover-layout-tests.sh for isolated preferences.")
}
defer { UserDefaults.standard.removePersistentDomain(forName: domain) }
// Use the existing manual-mode preference before constructing the real manager.
UserDefaults.standard.setVolatileDomain(["customMode": true], forName: UserDefaults.argumentDomain)
let manager = AudioManager()
precondition(manager.isCustomMode)
let host = NSHostingView(rootView: MenuBarView().environmentObject(manager))
let window = NSWindow(contentRect: NSRect(x: 100, y: 100, width: 340, height: 553),
styleMask: [.borderless], backing: .buffered, defer: false)
window.contentView = host
window.makeKeyAndOrderFront(nil)
defer { window.orderOut(nil) }

// Replace only the displayed rows, so results do not depend on connected hardware.
manager.speakerDevices = []
manager.headphoneDevices = []
for count in [3, 20] {
manager.inputDevices = (1...count).map {
AudioDevice(id: UInt32($0), uid: "layout-test-\($0)", name: "Test microphone \($0)", type: .input, isConnected: false)
}
for height: CGFloat in [553, 400, 300] {
host.rootView = MenuBarView(availableHeight: height).environmentObject(manager)
window.setContentSize(NSSize(width: 340, height: height))
host.layoutSubtreeIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.2))
guard host.bounds.height > 200 && host.bounds.height <= height + 1 else {
fputs("FAIL: requested \(height)-point panel, got \(host.bounds.height).\n", stderr)
exit(1)
}
guard host.fittingSize.height > 200 && host.fittingSize.height <= height else {
fputs("FAIL: ideal panel height is empty or exceeds the available height.\n", stderr)
exit(1)
}
print("PASS: \(count) rows, available \(Int(height)), actual \(Int(host.bounds.height)), ideal \(Int(host.fittingSize.height))")
}
}
if ProcessInfo.processInfo.environment["POPOVER_LAYOUT_HOLD"] == "1" {
print("Ready for native footer/scroll inspection at height 300; preview closes after two minutes.")
fflush(stdout)
Timer.scheduledTimer(withTimeInterval: 120, repeats: false) { _ in
UserDefaults.standard.removePersistentDomain(forName: domain)
app.terminate(nil)
}
app.run()
}
}
}
56 changes: 56 additions & 0 deletions Tests/popover-uat.applescript
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- Run with the target app running; optionally pass expected connected device names.
-- osascript Tests/popover-uat.applescript AudioPriorityBar "MacBook Air Microphone"
-- Checks the rendered viewport, then closes and reopens the popover and checks again.
-- Does not change audio devices, priorities, or automation settings.
on run arguments
if (count of arguments) < 1 then error "Pass the target app process name."
set appName to item 1 of arguments
set expectedNames to rest of arguments
set passes to 2
set minimumViewportHeight to 200
if expectedNames is not {} and item 1 of expectedNames is "--window-only" then
set expectedNames to rest of expectedNames
set passes to 1
set minimumViewportHeight to 1
end if
tell application "System Events" to tell process appName
repeat 30 times
if (count of windows) > 0 or (count of menu bars) > 1 then exit repeat
delay 0.1
end repeat
repeat with attempt from 1 to passes
if (count of windows) is 0 then
set iconPosition to position of menu bar item "Volume High" of menu bar 2
set iconSize to size of menu bar item "Volume High" of menu bar 2
tell application "System Events" to click at {(item 1 of iconPosition) + (item 1 of iconSize) / 2, (item 2 of iconPosition) + (item 2 of iconSize) / 2}
end if
repeat 20 times
if (count of windows) > 0 then exit repeat
delay 0.1
end repeat
set viewport to scroll area 1 of group 1 of window 1
set viewportSize to size of viewport
if (item 2 of viewportSize) < minimumViewportHeight then error "Device list collapsed: height=" & (item 2 of viewportSize)
set rowNames to name of every static text of viewport
repeat with expectedName in expectedNames
if rowNames does not contain (expectedName as text) then error "Missing microphone: " & expectedName
end repeat
set panelPosition to position of window 1
set panelSize to size of window 1
-- The top-level buttons include Login, Edit, and Quit, outside the scroll view.
repeat with panelButton in buttons of group 1 of window 1
set controlPosition to position of panelButton
set controlSize to size of panelButton
if (item 2 of controlPosition) < (item 2 of panelPosition) - 1 or (item 2 of controlPosition) + (item 2 of controlSize) > (item 2 of panelPosition) + (item 2 of panelSize) + 1 then error "Header or footer button is clipped"
end repeat
if attempt is 1 and passes is 2 then
set iconPosition to position of menu bar item "Volume High" of menu bar 2
set iconSize to size of menu bar item "Volume High" of menu bar 2
tell application "System Events" to click at {(item 1 of iconPosition) + (item 1 of iconSize) / 2, (item 2 of iconPosition) + (item 2 of iconSize) / 2}
delay 0.1
if (count of windows) is not 0 then error "Popover did not close when its menu icon was clicked"
end if
end repeat
return "PASS: viewport and top-level controls visible; expected microphones present. Passes=" & passes & "; height=" & (item 2 of viewportSize)
end tell
end run
27 changes: 27 additions & 0 deletions Tests/run-popover-layout-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/bin/sh
set -eu
repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
test_dir=$(mktemp -d "${TMPDIR:-/tmp}/popover-layout.XXXXXX")
trap 'rm -rf "$test_dir"' EXIT HUP INT TERM
app_dir="$test_dir/PopoverLayoutTests.app"
mkdir -p "$app_dir/Contents/MacOS"
cat > "$app_dir/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleIdentifier</key><string>app.audioprioritybar.layouttests.$(uuidgen)</string>
<key>CFBundleExecutable</key><string>PopoverLayoutTests</string>
<key>LSUIElement</key><true/>
</dict></plist>
PLIST
if [ "${POPOVER_LAYOUT_HOLD:-0}" = 1 ]; then printf '%s\n' "$app_dir"; fi
# Use the real views and manager, replacing only the app entry point.
sed '/^@main$/d' "$repo_dir/AudioPriorityBar/AudioPriorityBarApp.swift" > "$test_dir/AudioPriorityBarApp.swift"
xcrun swiftc -module-cache-path "$test_dir/modules" \
"$test_dir/AudioPriorityBarApp.swift" \
"$repo_dir"/AudioPriorityBar/Models/*.swift \
"$repo_dir"/AudioPriorityBar/Services/*.swift \
"$repo_dir"/AudioPriorityBar/Views/*.swift \
"$repo_dir/Tests/PopoverLayoutTests.swift" \
-o "$app_dir/Contents/MacOS/PopoverLayoutTests"
"$app_dir/Contents/MacOS/PopoverLayoutTests"