Skip to content
Merged
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
51 changes: 50 additions & 1 deletion TypeWhisper/Views/NotchIndicatorPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@ private class FirstMouseHostingView<Content: View>: NSHostingView<Content> {
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
}

/// Positions a fixed-size hosting view without involving SwiftUI in window sizing.
private final class NotchHostingContainerView: NSView {
private let hostingView: NSView

init(hostingView: NSView, size: NSSize) {
self.hostingView = hostingView
super.init(frame: NSRect(origin: .zero, size: size))
hostingView.frame = bounds
hostingView.autoresizingMask = []
addSubview(hostingView)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func resizeSubviews(withOldSize oldSize: NSSize) {
// Flexible margins do not reliably center an oversized subview when
// both horizontal margins initially have zero width. Move only the
// origin: resizing the hosting view reintroduces the layout feedback loop.
let origin = NSPoint(
x: bounds.midX - hostingView.frame.width / 2,
y: bounds.maxY - hostingView.frame.height
)
if hostingView.frame.origin != origin {
hostingView.setFrameOrigin(origin)
}
}
}

/// Panel that visually extends the MacBook notch, centered over the hardware notch.
/// Only shown on displays with a hardware notch - hidden on non-notch displays regardless of settings.
class NotchIndicatorPanel: NSPanel {
Expand Down Expand Up @@ -115,7 +145,26 @@ class NotchIndicatorPanel: NSPanel {

let hostingView = FirstMouseHostingView(rootView: content(notchGeometry))
hostingView.sizingOptions = []
contentView = hostingView
// The notch content lays itself out from NotchGeometry and never consumes
// the safe area; opting out also drops NSHostingView's safe-area-corner
// KVO observer, which this panel (the only window over the notch) would
// otherwise service on every frame change.
hostingView.safeAreaRegions = []

// The hosting view is never resized: it stays at the non-interactive
// panel size inside a plain container and only the window changes size.
// NSHostingView keeps a WindowSizeBridge that, whenever the root view's
// size changes inside an animated SwiftUI transaction, animates the
// window frame to follow it from windowDidLayout (observed live: the
// bridge asked to resize this panel back to 500x500 while it sat at the
// toast size). A window resize issued from inside the layout pass
// re-enters constraint updates and AppKit raises
// NSInternalInconsistencyException from _postWindowNeedsUpdateConstraints,
// which the display-cycle observer rethrows and the process aborts
// (#1229). With the hosting view's size constant the root size never
// animates, so the bridge has nothing to animate. The container explicitly
// centers and top-anchors the hosting view as the window changes size.
contentView = NotchHostingContainerView(hostingView: hostingView, size: initialSize)
}

override var canBecomeKey: Bool { false }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1594,8 +1594,88 @@ final class IndicatorPanelInteractionTests: XCTestCase {
}
}

@MainActor
private final class NotchLayoutProbeModel: ObservableObject {
@Published var feedback = false
var rootSizes: [CGSize] = []
var safeArea: EdgeInsets?
}

private struct NotchLayoutProbeView: View {
@ObservedObject var model: NotchLayoutProbeModel

var body: some View {
GeometryReader { geometry in
Color.black
.frame(width: model.feedback ? 340 : 400, height: model.feedback ? 86 : 160)
.animation(.easeOut(duration: 0.24), value: model.feedback)
.onAppear {
model.rootSizes.append(geometry.size)
model.safeArea = geometry.safeAreaInsets
}
.onChange(of: geometry.size) { model.rootSizes.append(geometry.size) }
.onChange(of: geometry.safeAreaInsets) { model.safeArea = geometry.safeAreaInsets }
}
}
}

@MainActor
final class NotchIndicatorPanelLifecycleTests: XCTestCase {
func testFixedHostingViewRemainsCenteredAndTopAlignedWhilePanelResizes() throws {
let panel = try makePanel()
defer { panel.orderOut(nil) }
let container = try XCTUnwrap(panel.contentView)
let hostingView = try XCTUnwrap(container.subviews.first)
let fixedSize = CGSize(width: 500, height: 500)

for size in [
CGSize(width: 340, height: 86),
fixedSize,
CGSize(width: 360, height: 90),
CGSize(width: 640, height: 120),
fixedSize
] {
let frame = CGRect(origin: CGPoint(x: 100, y: 200), size: size)
panel.setFrame(frame, display: false)
panel.layoutIfNeeded()

XCTAssertEqual(panel.frame, frame)
XCTAssertEqual(hostingView.frame.size, fixedSize)
XCTAssertEqual(hostingView.bounds.size, fixedSize)
XCTAssertEqual(hostingView.frame.midX, container.bounds.midX, accuracy: 0.01)
XCTAssertEqual(hostingView.frame.maxY, container.bounds.maxY, accuracy: 0.01)
}
}

func testFeedbackTransitionsKeepSwiftUIRootSizeAndIgnoreSystemSafeArea() async throws {
let model = NotchLayoutProbeModel()
let panel = try makePanel { _ in NotchLayoutProbeView(model: model) }
panel.alphaValue = 0
defer { panel.orderOut(nil) }
panel.show()
let container = try XCTUnwrap(panel.contentView)

for interactive in [false, true, false, true, false] {
withAnimation(.easeOut(duration: 0.24)) {
model.feedback = interactive
panel.updateFeedbackInteraction(isInteractive: interactive)
}
let expectedFrame = panel.frame
for inset in [CGFloat(32), 64, 0] {
container.additionalSafeAreaInsets = NSEdgeInsets(top: inset, left: 0, bottom: 0, right: 0)
panel.layoutIfNeeded()
try await Task.sleep(for: .milliseconds(50))

XCTAssertFalse(model.rootSizes.isEmpty)
XCTAssertTrue(model.rootSizes.allSatisfy { $0 == CGSize(width: 500, height: 500) })
XCTAssertEqual(model.safeArea, EdgeInsets())
XCTAssertEqual(panel.frame, expectedFrame)
XCTAssertFalse(panel.canBecomeKey)
XCTAssertFalse(panel.canBecomeMain)
}
}
}

func testPlacementRefreshDoesNotCancelInFlightDismissal() async throws {
let panel = try makePanel()
defer { panel.orderOut(nil) }
Expand Down Expand Up @@ -1662,6 +1742,12 @@ final class NotchIndicatorPanelLifecycleTests: XCTestCase {
}

private func makePanel() throws -> NotchIndicatorPanel {
try makePanel { _ in EmptyView() }
}

private func makePanel<Content: View>(
@ViewBuilder content: (NotchGeometry) -> Content
) throws -> NotchIndicatorPanel {
guard let screen = NSScreen.screens.first else {
throw XCTSkip("Notch indicator panel tests require an available screen")
}
Expand All @@ -1678,7 +1764,7 @@ final class NotchIndicatorPanelLifecycleTests: XCTestCase {
return NotchIndicatorPanel(
screenResolver: resolver,
displayModeProvider: { .activeScreen },
content: { _ in EmptyView() }
content: content
)
}
}
Expand Down
15 changes: 11 additions & 4 deletions TypeWhisperTests/MeetingAutomationCountdownIndicatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,28 +371,35 @@ final class MeetingAutomationCountdownIndicatorTests: XCTestCase {
screenResolver: resolver,
displayModeProvider: { .activeScreen },
countdownModel: model,
content: { _ in EmptyView() }
content: { _ in Color.black }
)
let overlay = OverlayIndicatorPanel(
screenResolver: resolver,
displayModeProvider: { .activeScreen },
overlayPositionProvider: { .top },
countdownModel: model,
content: { EmptyView() }
content: { Color.black }
)
let minimal = MinimalIndicatorPanel(
screenResolver: resolver,
displayModeProvider: { .activeScreen },
overlayPositionProvider: { .top },
countdownModel: model,
content: { EmptyView() }
content: { Color.black }
)
let panels: [NSPanel] = [notch, overlay, minimal]

for panel in panels {
XCTAssertFalse(panel.canBecomeKey)
XCTAssertFalse(panel.canBecomeMain)
XCTAssertTrue(panel.contentView?.acceptsFirstMouse(for: nil) == true)
let content = try XCTUnwrap(panel.contentView)
content.layoutSubtreeIfNeeded()
let point = content.convert(
CGPoint(x: content.bounds.midX, y: content.bounds.midY),
to: content.superview
)
let hitView = try XCTUnwrap(content.hitTest(point))
XCTAssertTrue(hitView.acceptsFirstMouse(for: nil))
}
}
}