Die Notch wird zur Insel, wo es keine Hardware gibt

Die Silhouette bestand aus zwei überlappenden Teilpfaden. Gefüllt stimmte die
Vereinigung, aber die Kontur zeichnete jeden Teilpfad vollständig nach — auch
dort, wo er innen lag. Dazu kippte die Form ab einer Breite in einen anderen
Zweig, und die beiden Zweige waren nicht dieselbe Geometrie. Zusammen war das
das Flackern an den Rundungen. Jetzt ein durchgehender Umriss, dessen Zweige
ineinander übergehen statt zu springen.

Neu ist die Wahl zwischen verschmolzen und schwebend. Eine echte Notch **muss**
an der Kante kleben und oben eckig sein, sonst klafft ein Spalt zum Gehäuse;
eine Insel **muss** Abstand halten und rundum gerundet sein, sonst sieht sie aus
wie ein abgeschnittenes Rechteck. Beides zu mischen geht nicht, deshalb eine
Wahl und keine zwei Schalter.

Ihre Höhe rät Onyx nicht mehr, sondern misst die Menüleiste: 24 Punkte minus
Luft oben und unten. Vorher hing ein festes Maß unten heraus.

Dazu „nur der Hauptbildschirm" als dritte Anzeigewahl — gemeint ist der mit der
Menüleiste, nicht `NSScreen.main`, das mit dem aktiven Fenster mitwandert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 21:05:13 +02:00
co-authored by Claude Opus 5
parent ecb39fbe5f
commit c05ccb8ae1
12 changed files with 1081 additions and 104 deletions
@@ -0,0 +1,107 @@
import Testing
@testable import OnyxNotch
@Suite("Eingeklappt und trotzdem da")
struct AmbientStateTests {
/// Öffnet das Panel auf dem üblichen Weg: hinein, Entprellzeit ab.
private func opened() -> NotchStateMachine {
var machine = NotchStateMachine()
machine.handle(.pointerEntered)
machine.handle(.armTimerFired)
return machine
}
@Test("Ohne Live Activity ist eingeklappt gleich unsichtbar")
func idleIsOffScreen() {
let machine = NotchStateMachine()
#expect(!machine.isExpanded)
#expect(!machine.isOnScreen)
}
/// Der ganze Zweck: Fenster auf dem Schirm, Fläche in Notchgröße.
@Test("Eine Live Activity holt das Fenster, ohne auszufahren")
func ambientShowsWithoutExpanding() {
var machine = NotchStateMachine()
let effects = machine.handle(.ambient(true))
#expect(effects == [.showAmbient])
#expect(machine.isOnScreen)
#expect(!machine.isExpanded)
}
@Test("Fällt sie weg, geht das Fenster wieder fort")
func ambientVanishing() {
var machine = NotchStateMachine()
machine.handle(.ambient(true))
#expect(machine.handle(.ambient(false)) == [.hideAmbient])
#expect(!machine.isOnScreen)
}
/// Zweimal dieselbe Meldung ist keine Änderung. Ohne diese Sperre blendet
/// jeder Lautstärkeschritt das Fenster neu ein.
@Test("Dieselbe Meldung zweimal bewirkt nichts")
func repeatedIsQuiet() {
var machine = NotchStateMachine()
machine.handle(.ambient(true))
#expect(machine.handle(.ambient(true)).isEmpty)
}
/// Ausgefahren ist ohnehin alles zu sehen — dort darf eine Activity das
/// Fenster nicht ein zweites Mal einblenden.
@Test("Ausgefahren bleibt eine Live Activity ohne Wirkung")
func ambientWhileExpanded() {
var machine = opened()
#expect(machine.handle(.ambient(true)).isEmpty)
#expect(machine.isExpanded)
#expect(machine.isOnScreen)
}
/// Der eigentliche Grund für die Trennung: nach dem Einfahren darf das
/// Fenster nicht fortgeräumt werden, wenn noch etwas anliegt.
@Test("Nach dem Einfahren bleibt das Fenster, solange etwas anliegt")
func staysAfterCollapse() {
var machine = opened()
machine.handle(.ambient(true))
machine.handle(.pointerExited)
machine.handle(.closeTimerFired)
#expect(!machine.isExpanded)
#expect(machine.isOnScreen)
#expect(machine.hasAmbient)
}
@Test("Unterdrückung nimmt auch die Live Activity weg")
func suppressionHidesAmbient() {
var machine = NotchStateMachine()
machine.handle(.ambient(true))
let effects = machine.handle(.suppressed(true))
#expect(effects.contains(.hideAmbient))
#expect(!machine.isOnScreen)
// Gemerkt bleibt sie trotzdem — sonst käme sie nach dem Vollbildvideo
// nicht von selbst zurück.
#expect(machine.hasAmbient)
}
@Test("Nach der Unterdrückung kommt sie von selbst zurück")
func ambientReturnsAfterSuppression() {
var machine = NotchStateMachine()
machine.handle(.ambient(true))
machine.handle(.suppressed(true))
#expect(machine.handle(.suppressed(false)) == [.showAmbient])
#expect(machine.isOnScreen)
}
/// Eine Activity darf die Phase nicht anfassen: sonst gälte ein Klick
/// plötzlich als Fixieren, oder eine Frist liefe, die niemand gestartet hat.
@Test("Eine Live Activity ändert die Phase nicht")
func ambientLeavesPhaseAlone() {
var machine = NotchStateMachine()
machine.handle(.pointerEntered)
let before = machine.phase
machine.handle(.ambient(true))
#expect(machine.phase == before)
}
}
@@ -53,10 +53,44 @@ struct DisplayPolicyTests {
#expect(chosen.count == 1)
}
// MARK: - Hauptbildschirm
/// Ein externer Bildschirm, der die Menüleiste trägt — der übliche Fall am
/// Schreibtisch, wenn der Laptop danebensteht.
static let externalMain = ScreenMetrics(
frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, isMain: true, displayID: 2
)
@Test("Hauptbildschirm: der mit der Menüleiste, auch wenn er extern ist")
func mainDisplayPicksTheMenuBarScreen() {
let chosen = DisplayPolicy.mainDisplay
.screens(from: [Self.builtIn, Self.externalMain, Self.secondExternal])
#expect(chosen.map(\.displayID) == [2])
}
@Test("Hauptbildschirm: genau einer, nie mehrere")
func mainDisplayPicksExactlyOne() {
let chosen = DisplayPolicy.mainDisplay
.screens(from: [Self.builtIn, Self.externalMain, Self.secondExternal])
#expect(chosen.count == 1)
}
@Test("Hauptbildschirm: meldet ihn niemand, bleibt Onyx trotzdem erreichbar")
func mainDisplayFallsBackWhenNobodyClaimsIt() {
// Kann im Betrieb nicht vorkommen — die Menüleiste ist immer irgendwo.
// Ein leeres Ergebnis hieße aber: keine Notch, kein Zugang, kein Weg
// zurück in die Einstellungen.
let chosen = DisplayPolicy.mainDisplay.screens(from: [Self.external, Self.secondExternal])
#expect(chosen.count == 1)
}
@Test("Ohne Displays kein Panel — und kein Absturz")
func noScreensIsSafe() {
#expect(DisplayPolicy.builtInOnly.screens(from: []).isEmpty)
#expect(DisplayPolicy.allDisplays.screens(from: []).isEmpty)
#expect(DisplayPolicy.mainDisplay.screens(from: []).isEmpty)
}
@Test("Mehrere eingebaute Displays gibt es nicht, aber der Code darf daran nicht scheitern")
@@ -0,0 +1,65 @@
import Testing
import CoreGraphics
@testable import OnyxNotch
@Suite("Band neben der Notch")
struct NotchBandTests {
// Die nachgemessenen Werte des Zielgeräts: 1800 Punkte breiter Bildschirm,
// Notch 220 breit, bei fünf Kacheln ein 972 Punkte breites Panel.
private let notchWidth: CGFloat = 220
private let panelWidth: CGFloat = 972
@Test("Bei fünf Kacheln bleiben je 376 Punkte")
func referenceMachine() {
let band = NotchBandLayout.resolve(panelWidth: panelWidth, notchWidth: notchWidth)
#expect(band.leading == 376)
#expect(band.trailing == 376)
#expect(!band.isEmpty)
}
@Test("Der gezeichnete Streifen ist wieder so breit wie das Panel")
func totalMatchesPanel() {
let band = NotchBandLayout.resolve(panelWidth: panelWidth, notchWidth: notchWidth)
#expect(band.totalWidth(notchWidth: notchWidth) == panelWidth)
}
/// Zusammengezogen ist das Panel genau die Notch. Dann gibt es keine Arme —
/// und erst recht keine negativen.
@Test("Ist das Panel so breit wie die Notch, gibt es kein Band")
func collapsedHasNoBand() {
let band = NotchBandLayout.resolve(panelWidth: notchWidth, notchWidth: notchWidth)
#expect(band == .none)
#expect(band.isEmpty)
}
/// Kommt auf sehr schmalen Bildschirmen vor, wenn das Panel geklemmt wird.
/// Eine negative Breite wäre in einer `frame`-Angabe ein Absturz.
@Test("Ein Panel schmaler als die Notch ergibt keine negative Breite")
func narrowerThanNotchStaysZero() {
let band = NotchBandLayout.resolve(panelWidth: 120, notchWidth: notchWidth)
#expect(band == .none)
}
@Test("Knapp unter dem Mindestmaß bleibt der Streifen leer")
func belowMinimumIsEmpty() {
let width = notchWidth + 2 * (NotchBandLayout.minimumUsableWidth - 1)
#expect(NotchBandLayout.resolve(panelWidth: width, notchWidth: notchWidth) == .none)
}
@Test("Genau am Mindestmaß trägt er")
func atMinimumIsUsed() {
let width = notchWidth + 2 * NotchBandLayout.minimumUsableWidth
let band = NotchBandLayout.resolve(panelWidth: width, notchWidth: notchWidth)
#expect(band.leading == NotchBandLayout.minimumUsableWidth)
}
/// Ein Arm links ohne Gegenstück rechts liest sich als verrutschtes Panel.
@Test("Beide Seiten sind immer gleich breit")
func bothSidesMatch() {
for width in stride(from: CGFloat(200), through: 2000, by: 37) {
let band = NotchBandLayout.resolve(panelWidth: width, notchWidth: notchWidth)
#expect(band.leading == band.trailing)
}
}
}
@@ -43,8 +43,71 @@ struct NotchGeometryTests {
let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize)
#expect(g.kind == .virtual)
// Mittig: 1800 + (2560 − 220)/2 = 2970
#expect(g.rect == CGRect(x: 2970, y: 1408, width: 220, height: 32))
#expect(g.isDetached)
// Mittig: 1800 + (2560 − 220)/2 = 2970.
// Und **nicht** an der Oberkante: ohne Hardware gibt es nichts zum
// Verschmelzen, also schwebt die Insel. Dieser Bildschirm meldet keine
// Menüleistenhöhe, deshalb bleibt es beim vorgegebenen Maß und der
// kleinen Luft: 1440 − 3 − 32 = 1405.
#expect(g.rect == CGRect(x: 2970, y: 1405, width: 220, height: 32))
}
@Test("Die Insel liegt in der Menüleiste, nicht darunter")
func islandFitsIntoTheMenuBar() {
// Ein Bildschirm, der seine Menüleiste meldet — der Normalfall.
let screen = ScreenMetrics(
frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, menuBarHeight: 24, displayID: 2)
let g = NotchGeometry.resolve(screen, virtualSize: CGSize(width: 72, height: 26))
#expect(g.isDetached)
// 24 minus je 3 Punkt Luft: die vorgegebenen 26 wären zu hoch gewesen
// und hätten unten aus der Leiste herausgeragt.
#expect(g.rect.height == 18)
// Oben und unten gleich viel Luft.
#expect(screen.frame.maxY - g.rect.maxY == 3)
#expect(g.rect.minY - (screen.frame.maxY - screen.menuBarHeight) == 3)
}
@Test("Eine sehr flache Menüleiste drückt die Insel nicht zum Strich")
func islandKeepsAMinimumHeight() {
let screen = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, menuBarHeight: 12, displayID: 9)
let g = NotchGeometry.resolve(screen, virtualSize: CGSize(width: 72, height: 26))
#expect(g.rect.height == NotchGeometry.minimumIslandHeight)
}
@Test("Echte Notch verschmilzt und schwebt nicht")
func physicalNotchStaysMerged() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)
#expect(g.kind == .physical)
#expect(!g.isDetached)
// Kein Spalt: die Fläche *ist* die Kante.
#expect(g.topMargin == 0)
}
@Test("Stil „immer schwebend\": auch mit Notch hängt die Insel darunter")
func islandStyleDetachesEvenWithHardware() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize,
style: .island)
#expect(g.isDetached)
// Unter der Hardware, nicht in ihr: sonst wäre die Insel zur Hälfte
// schwarz auf schwarzem Gehäuse.
#expect(g.topMargin > Self.builtIn.safeAreaTop)
}
@Test("Stil „immer verschmolzen\": ohne Hardware klebt die Fläche an der Kante")
func notchStyleStaysAttachedWithoutHardware() {
let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize,
style: .notch)
#expect(!g.isDetached)
#expect(g.topMargin == 0)
#expect(g.rect.maxY == Self.external.frame.maxY)
}
@Test("safeAreaTop ohne Hilfsflächen gilt nicht als Notch")
@@ -128,7 +191,10 @@ struct NotchGeometryTests {
let panel = g.panelRect(for: CGSize(width: 640, height: 320))
#expect(panel.minX >= 1800)
#expect(panel.maxY == 1440)
// Die Oberkante des Fensters folgt der schwebenden Insel, nicht der
// Bildschirmkante — sonst klebte das Panel oben fest, während die
// Insel darunter hängt.
#expect(panel.maxY == 1440 - NotchGeometry.detachedMargin)
}
// MARK: - Sichtbarer Inhaltsbereich
@@ -181,3 +247,57 @@ struct NotchGeometryTests {
#expect(panel.minY >= Self.builtIn.frame.minY)
}
}
@Suite("Hardware-Erkennung")
struct HardwareNotchTests {
@Test("Ein Bildschirm mit Hilfsflächen hat eine echte Notch")
func builtInHasHardware() {
let metrics = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 1800, height: 1169),
safeAreaTop: 38,
auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38),
auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38),
isBuiltIn: true, displayID: 1)
#expect(NotchGeometry.hardwareRect(metrics) != nil)
}
@Test("Ein externer Bildschirm hat keine")
func externalHasNone() {
let metrics = ScreenMetrics(
frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, displayID: 2)
#expect(NotchGeometry.hardwareRect(metrics) == nil)
}
@Test("safeAreaTop allein genügt nicht")
func insetAloneIsNotANotch() {
// Bei Spiegelung und Fernanzeige ist das Inset gesetzt, obwohl es
// nichts gibt, um das man herumlegen könnte.
let metrics = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 1800, height: 1169),
safeAreaTop: 38, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: true, displayID: 3)
#expect(NotchGeometry.hardwareRect(metrics) == nil)
}
@Test("Die Einstellungen und die Geometrie kommen zum selben Schluss")
func settingsAgreeWithGeometry() {
// Genau dafür ist `hardwareRect` öffentlich: würden beide die Frage
// getrennt beantworten, zeigten die Einstellungen irgendwann einen
// Regler für eine Breite, die gar nicht gilt.
let builtIn = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 1800, height: 1169),
safeAreaTop: 38,
auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38),
auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38),
isBuiltIn: true, displayID: 1)
let hasHardware = NotchGeometry.hardwareRect(builtIn) != nil
let geometry = NotchGeometry.resolve(builtIn, virtualSize: CGSize(width: 72, height: 26))
#expect(NotchStyle.automatic.merges(withHardware: hasHardware))
#expect(geometry.kind == .physical)
}
}