Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift
Scarriffle a54b332723 Notch: Toleranz für zitternde Hände, „Panel öffnen" fliegt raus
Warum meine Proben grün waren und deine Hand nicht: jedes einzelne
Abtastbild außerhalb der Auslösefläche hat das Aufziehen sofort
abgebrochen. Ein Zeiger, den eine Hand an den oberen Rand führt, steht
dort aber nicht still — er wandert um ein paar Punkte. Damit fing die
Entprellzeit von 220 ms dauernd von vorn an und kam nie ans Ziel.
`CGWarpMouseCursorPosition` setzt den Zeiger auf einen Punkt und hält ihn
absolut still; deshalb war jede automatisierte Probe 19 von 19 grün.

Kurzes Herausrutschen bricht jetzt nicht mehr ab: die Entprellzeit läuft
weiter, parallel läuft die Abbruchfrist von 300 ms. Kommt der Zeiger
zurück, wird die Frist gestoppt — die Entprellzeit setzt bewusst nicht neu
an, sonst dauerte es bei jedem Grenzübertritt wieder von vorn.

Das allein hätte einen neuen Fehler gebracht, und ein vorhandener Test hat
ihn gefunden: beim beiläufigen Streifen der Notch wäre die Entprellzeit
abgelaufen, während der Zeiger längst weg war — das Panel wäre für ein
Zehntel aufgeblitzt. Die Maschine merkt sich deshalb, wo der Zeiger steht,
und öffnet nur, wenn er da ist. Ist die Entprellzeit abgelaufen, während er
draußen war, geht es beim Zurückkommen ohne weiteres Warten auf.

Die Auslösefläche reicht außerdem 14 statt 4 Punkte unter die Notch. Vier
Punkte sind keine Fläche, die eine Hand trifft.

„Panel öffnen" ist aus dem Menü verschwunden. Ein Menüeintrag für etwas,
das beim Berühren der Notch von selbst passiert, beschreibt einen Umweg um
die eigentliche Bedienung herum. Als Notausgang bleibt es erreichbar, nur
ohne eigene Zeile: Linksklick auf das Symbol fährt das Panel aus,
Rechtsklick zeigt Einstellungen und Beenden.

Dazu Protokollierung jedes Zustandswechsels und jeder Unterdrückung. Ohne
diese Spur ist ein Panel, das sich „manchmal nicht öffnet", nicht zu
untersuchen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:12:31 +02:00

166 lines
6.8 KiB
Swift

import CoreGraphics
/// Die Bildschirmwerte, aus denen sich die Notch ableiten lässt als eigener
/// Typ, damit die Geometrie ohne echten `NSScreen` prüfbar bleibt.
public struct ScreenMetrics: Equatable, Sendable, Identifiable {
public let frame: CGRect
public let safeAreaTop: CGFloat
public let auxiliaryTopLeft: CGRect?
public let auxiliaryTopRight: CGRect?
public let isBuiltIn: Bool
/// `CGDirectDisplayID` stabil über Anordnungswechsel hinweg, anders als
/// der Index in `NSScreen.screens`.
public let displayID: UInt32
public var id: UInt32 { displayID }
public init(frame: CGRect,
safeAreaTop: CGFloat,
auxiliaryTopLeft: CGRect?,
auxiliaryTopRight: CGRect?,
isBuiltIn: Bool = false,
displayID: UInt32 = 0) {
self.frame = frame
self.safeAreaTop = safeAreaTop
self.auxiliaryTopLeft = auxiliaryTopLeft
self.auxiliaryTopRight = auxiliaryTopRight
self.isBuiltIn = isBuiltIn
self.displayID = displayID
}
}
/// Auf welchen Displays Onyx überhaupt erscheint.
public enum DisplayPolicy: String, Codable, Sendable, CaseIterable {
/// Nur das eingebaute Display.
case builtInOnly
/// Jedes angeschlossene Display, externe mit virtueller Notch.
case allDisplays
public func screens(from available: [ScreenMetrics]) -> [ScreenMetrics] {
switch self {
case .allDisplays:
return available
case .builtInOnly:
let builtIn = available.filter(\.isBuiltIn)
// Am Dock mit geschlossenem Deckel gibt es kein eingebautes Display.
// Diese Einstellung beim Wort zu nehmen hieße, Onyx unerreichbar zu
// machen deshalb ein einzelnes Ersatzdisplay statt gar keinem.
guard builtIn.isEmpty else { return builtIn }
return Array(available.prefix(1))
}
}
}
/// Wo die Notch sitzt und wo das Panel dazu hingehört.
///
/// Auf dem eingebauten Display wird die echte Notch aus den beiden Hilfsflächen
/// links und rechts davon berechnet. Auf allen anderen Displays tritt ein
/// virtueller Ersatz an dieselbe Stelle, damit Onyx dort überhaupt erreichbar ist.
public struct NotchGeometry: Equatable, Sendable {
public enum Kind: Equatable, Sendable {
/// Aus `auxiliaryTopLeftArea` / `auxiliaryTopRightArea` abgeleitet.
case physical
/// Frei gesetzter Balken oben mittig.
case virtual
}
public let kind: Kind
/// Die Notch selbst, in Bildschirmkoordinaten (Ursprung unten links).
public let rect: CGRect
public let screenFrame: CGRect
/// Wie weit die Auslösefläche über die Notch hinausreicht. Ohne diesen Rand
/// muss man die Notch pixelgenau treffen, und die unteren Bildpunkte der
/// Notch selbst nimmt die Menüleiste bereits für sich in Anspruch.
public static let triggerInsetX: CGFloat = 16
/// Nach unten deutlich großzügiger als nach oben: von oben kommt niemand,
/// dort ist der Bildschirmrand. Vier Punkte waren zu wenig eine Hand
/// hält den Zeiger nicht auf vier Punkte genau.
public static let triggerInsetY: CGFloat = 14
public init(kind: Kind, rect: CGRect, screenFrame: CGRect) {
self.kind = kind
self.rect = rect
self.screenFrame = screenFrame
}
public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize) -> NotchGeometry {
// Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am
// Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt,
// obwohl es nichts gibt, um das man herumlegen könnte.
if let left = metrics.auxiliaryTopLeft,
let right = metrics.auxiliaryTopRight,
metrics.safeAreaTop > 0 {
let width = metrics.frame.width - left.width - right.width
if width > 0 {
return NotchGeometry(
kind: .physical,
rect: CGRect(x: left.maxX,
y: metrics.frame.maxY - metrics.safeAreaTop,
width: width,
height: metrics.safeAreaTop),
screenFrame: metrics.frame)
}
}
let width = min(virtualSize.width, metrics.frame.width)
return NotchGeometry(
kind: .virtual,
rect: CGRect(x: metrics.frame.midX - width / 2,
y: metrics.frame.maxY - virtualSize.height,
width: width,
height: virtualSize.height),
screenFrame: metrics.frame)
}
/// Die Fläche, deren Betreten das Panel scharf schaltet.
public var triggerRect: CGRect {
rect
.insetBy(dx: -Self.triggerInsetX, dy: -Self.triggerInsetY)
.clamped(to: screenFrame)
}
/// Der Bereich, in dem Inhalt tatsächlich zu sehen ist.
///
/// Das Panel reicht bis an die Bildschirmoberkante, damit seine Fläche mit
/// der schwarzen Notch zu einem Körper verschmilzt. Die Notch ist aber
/// undurchsichtig was dort steht, ist weg. Auch links und rechts daneben
/// liegt in diesem Streifen die Menüleiste. Der nutzbare Bereich beginnt
/// deshalb vollständig unterhalb.
public func contentRect(for contentSize: CGSize) -> CGRect {
let full = panelRect(for: CGSize(width: contentSize.width,
height: contentSize.height + rect.height))
return CGRect(x: full.minX,
y: full.minY,
width: full.width,
height: max(full.height - rect.height, 0))
}
/// Wohin das Panel gehört: auf die Notch zentriert, oben bündig mit dem
/// Bildschirmrand es wächst aus der Notch heraus, statt darunter zu schweben.
public func panelRect(for size: CGSize) -> CGRect {
let width = min(size.width, screenFrame.width)
let height = min(size.height, screenFrame.height)
return CGRect(x: rect.midX - width / 2,
y: screenFrame.maxY - height,
width: width,
height: height)
.clamped(to: screenFrame)
}
}
extension CGRect {
/// Schiebt das Rechteck in die Grenzen hinein, statt es abzuschneiden.
/// Ein zurechtgestutztes Panel wäre halb unsichtbar; ein verschobenes ist
/// vollständig da.
func clamped(to bounds: CGRect) -> CGRect {
let width = Swift.min(width, bounds.width)
let height = Swift.min(height, bounds.height)
let x = Swift.min(Swift.max(minX, bounds.minX), bounds.maxX - width)
let y = Swift.min(Swift.max(minY, bounds.minY), bounds.maxY - height)
return CGRect(x: x, y: y, width: width, height: height)
}
}