Auslösefläche einstellbar, Blitz immer lesbar, Menüleiste schmaler

Der Blitz war ein Loch in der Füllung — über dem gefüllten Teil liest man
ihn, über dem leeren ist es Transparenz auf Transparenz. Bei wenig Ladung
sitzt er fast ganz im Leeren und verschwindet.

Statt zwischen Schwarz und Weiß zu faden geht er den Weg von macOS: geteilt
gezeichnet, über der Füllung ausgestanzt, daneben ausgemalt. Damit ist er
bei jedem Ladestand lesbar, ohne Bewegung in der Menüleiste. Ein Test
prüft, dass sich die beiden Teile an der Kante treffen statt zu überlappen —
sonst wäre der Blitz an der Nahtstelle dicker.

Die Auslösefläche der Notch lässt sich jetzt einstellen, mit Vorschau. Wie
genau jemand die Notch trifft, hängt an Hand und Zeigegerät; feste Werte im
Code waren dafür die falsche Antwort. Die Vorschau zeigt einen
nachgebildeten Bildschirm mit Notch und eingefärbter Fläche — „24 Punkte
unter der Notch" ist keine Vorstellung, die jemand im Kopf hat.

Die Menüleistenbreiten folgen jetzt dem, was dasteht. „Temp 53°" stand in
einer Box für „Temp 100 °C", und in der Menüleiste ist Platz das knappste
Gut. Dieselbe Hysterese wie beim Netzwerk: sofort wachsen, zögernd
schrumpfen.

Das Mixer-Symbol war ausgeschaltet auf 55 % abgeblendet. Das war meine Idee
und ein Fehlgriff: über einem hellen Schreibtischhintergrund ist es damit
praktisch weg. Ob der Mixer läuft, erfährt man beim Klick — dafür muss man
das Symbol aber erst finden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-13 12:46:02 +02:00
parent bf799d57d9
commit a84e5a0c8e
18 changed files with 536 additions and 50 deletions

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#1c1f1f" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0 108.707 158.934a16.2 16.2 0 0 0-1.416 3.636L50.52 384.169a10.8 10.8 0 0 0 .081 5.665l126.344 121.391c1.267.507 2.631.775 4.021.775h150.07c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.569-1.866.635-3.883.134-5.84" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#273a3d" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0a10.78 10.78 0 0 0-8.035 3.584l-3.811 4.233 133.203 147.924a16.2 16.2 0 0 1 3.659 6.829l56.77 221.599a10.81 10.81 0 0 1-3.015 10.511L314.801 509.015a10.8 10.8 0 0 1-7.46 2.985h23.694c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.57-1.866.636-3.883.135-5.84" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#273a3d" d="M401.051 155.741 264.035 3.584A10.77 10.77 0 0 0 256.001 0v188.536l62.219 54.012 85.136-83.491a15.8 15.8 0 0 0-2.305-3.316" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#000000" d="M377.357 155.741a16.2 16.2 0 0 1 3.659 6.829l3.766 14.701 18.573-18.213a15.8 15.8 0 0 0-2.305-3.317L264.035 3.584A10.77 10.77 0 0 0 256.001 0v20.972z" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#0f1b1a" d="M247.966 3.584 110.95 155.741a15.9 15.9 0 0 0-2.302 3.319l85.1 83.456L256 188.537V0a10.77 10.77 0 0 0-8.034 3.584" opacity="1" data-original="#d4f2f6" class=""></path><path fill="#1c1f1f" d="M256.001 0a10.77 10.77 0 0 0-8.035 3.584l-3.811 4.233 11.846 13.155z" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#273a3d" d="M357.937 371.972 299.07 419.94l35.985 91.286.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#000000" d="m461.346 390.009-23.276-4.06a10.8 10.8 0 0 1-3.298 8.731L328.891 495.587l6.163 15.639.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#273a3d" d="m149.944 371.907-99.298 18.091a10.8 10.8 0 0 0 2.888 4.683l119.971 114.336a10.8 10.8 0 0 0 3.439 2.209l35.965-91.22z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#141414" d="m209.819 418.393-57.237-42.83a7.57 7.57 0 0 1-2.612-8.553l42.606-122.127a10.8 10.8 0 0 1 2.969-4.468l55.387-49.932a7.57 7.57 0 0 1 10.138 0l55.257 49.814a10.8 10.8 0 0 1 3.071 4.777l38.574 122.228a7.57 7.57 0 0 1-2.482 8.183l-53.224 42.684a10.82 10.82 0 0 1-6.767 2.379h-79.203a10.8 10.8 0 0 1-6.477-2.155" opacity="1" data-original="#eaf9fa" class=""></path></g></svg> <svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#d4d7d8" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0 108.707 158.934a16.2 16.2 0 0 0-1.416 3.636L50.52 384.169a10.8 10.8 0 0 0 .081 5.665l126.344 121.391c1.267.507 2.631.775 4.021.775h150.07c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.569-1.866.635-3.883.134-5.84" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#e9e9e9" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0a10.78 10.78 0 0 0-8.035 3.584l-3.811 4.233 133.203 147.924a16.2 16.2 0 0 1 3.659 6.829l56.77 221.599a10.81 10.81 0 0 1-3.015 10.511L314.801 509.015a10.8 10.8 0 0 1-7.46 2.985h23.694c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.57-1.866.636-3.883.135-5.84" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#e9e9e9" d="M401.051 155.741 264.035 3.584A10.77 10.77 0 0 0 256.001 0v188.536l62.219 54.012 85.136-83.491a15.8 15.8 0 0 0-2.305-3.316" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#d4d4d4" d="M377.357 155.741a16.2 16.2 0 0 1 3.659 6.829l3.766 14.701 18.573-18.213a15.8 15.8 0 0 0-2.305-3.317L264.035 3.584A10.77 10.77 0 0 0 256.001 0v20.972z" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#e5e5e5" d="M247.966 3.584 110.95 155.741a15.9 15.9 0 0 0-2.302 3.319l85.1 83.456L256 188.537V0a10.77 10.77 0 0 0-8.034 3.584" opacity="1" data-original="#d4f2f6" class=""></path><path fill="#d4d7d8" d="M256.001 0a10.77 10.77 0 0 0-8.035 3.584l-3.811 4.233 11.846 13.155z" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#e9e9e9" d="M357.937 371.972 299.07 419.94l35.985 91.286.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#d4d4d4" d="m461.346 390.009-23.276-4.06a10.8 10.8 0 0 1-3.298 8.731L328.891 495.587l6.163 15.639.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#e9e9e9" d="m149.944 371.907-99.298 18.091a10.8 10.8 0 0 0 2.888 4.683l119.971 114.336a10.8 10.8 0 0 0 3.439 2.209l35.965-91.22z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#f4f4f4" d="m209.819 418.393-57.237-42.83a7.57 7.57 0 0 1-2.612-8.553l42.606-122.127a10.8 10.8 0 0 1 2.969-4.468l55.387-49.932a7.57 7.57 0 0 1 10.138 0l55.257 49.814a10.8 10.8 0 0 1 3.071 4.777l38.574 122.228a7.57 7.57 0 0 1-2.482 8.183l-53.224 42.684a10.82 10.82 0 0 1-6.767 2.379h-79.203a10.8 10.8 0 0 1-6.477-2.155" opacity="1" data-original="#eaf9fa" class=""></path></g></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#505050" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0 108.707 158.934a16.2 16.2 0 0 0-1.416 3.636L50.52 384.169a10.8 10.8 0 0 0 .081 5.665l126.344 121.391c1.267.507 2.631.775 4.021.775h150.07c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.569-1.866.635-3.883.134-5.84" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#7a7a7a" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0a10.78 10.78 0 0 0-8.035 3.584l-3.811 4.233 133.203 147.924a16.2 16.2 0 0 1 3.659 6.829l56.77 221.599a10.81 10.81 0 0 1-3.015 10.511L314.801 509.015a10.8 10.8 0 0 1-7.46 2.985h23.694c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.57-1.866.636-3.883.135-5.84" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#7a7a7a" d="M401.051 155.741 264.035 3.584A10.77 10.77 0 0 0 256.001 0v188.536l62.219 54.012 85.136-83.491a15.8 15.8 0 0 0-2.305-3.316" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#5d5d5d" d="M377.357 155.741a16.2 16.2 0 0 1 3.659 6.829l3.766 14.701 18.573-18.213a15.8 15.8 0 0 0-2.305-3.317L264.035 3.584A10.77 10.77 0 0 0 256.001 0v20.972z" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#5b5b5b" d="M247.966 3.584 110.95 155.741a15.9 15.9 0 0 0-2.302 3.319l85.1 83.456L256 188.537V0a10.77 10.77 0 0 0-8.034 3.584" opacity="1" data-original="#d4f2f6" class=""></path><path fill="#505050" d="M256.001 0a10.77 10.77 0 0 0-8.035 3.584l-3.811 4.233 11.846 13.155z" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#7a7a7a" d="M357.937 371.972 299.07 419.94l35.985 91.286.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#5d5d5d" d="m461.346 390.009-23.276-4.06a10.8 10.8 0 0 1-3.298 8.731L328.891 495.587l6.163 15.639.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#7a7a7a" d="m149.944 371.907-99.298 18.091a10.8 10.8 0 0 0 2.888 4.683l119.971 114.336a10.8 10.8 0 0 0 3.439 2.209l35.965-91.22z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#434242" d="m209.819 418.393-57.237-42.83a7.57 7.57 0 0 1-2.612-8.553l42.606-122.127a10.8 10.8 0 0 1 2.969-4.468l55.387-49.932a7.57 7.57 0 0 1 10.138 0l55.257 49.814a10.8 10.8 0 0 1 3.071 4.777l38.574 122.228a7.57 7.57 0 0 1-2.482 8.183l-53.224 42.684a10.82 10.82 0 0 1-6.767 2.379h-79.203a10.8 10.8 0 0 1-6.477-2.155" opacity="1" data-original="#eaf9fa" class=""></path></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -2,13 +2,13 @@
"fill-specializations" : [ "fill-specializations" : [
{ {
"value" : { "value" : {
"automatic-gradient" : "display-p3:0.21900,0.30045,0.61471,1.00000" "automatic-gradient" : "display-p3:0.23039,0.38246,0.66250,1.00000"
} }
}, },
{ {
"appearance" : "dark", "appearance" : "dark",
"value" : { "value" : {
"automatic-gradient" : "display-p3:0.12445,0.17422,0.36459,1.00000" "automatic-gradient" : "display-p3:0.26369,0.36702,0.79660,1.00000"
} }
} }
], ],
@@ -16,8 +16,29 @@
{ {
"layers" : [ "layers" : [
{ {
"image-name" : "quartz.svg", "hidden" : false,
"name" : "quartz", "image-name" : "quartz (2).svg",
"name" : "quartz (2)",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "dark",
"value" : 0
}
],
"position" : {
"scale" : 1.39,
"translation-in-points" : [
0,
0
]
}
},
{
"image-name" : "quartz (4).svg",
"name" : "quartz (4)",
"position" : { "position" : {
"scale" : 1.39, "scale" : 1.39,
"translation-in-points" : [ "translation-in-points" : [

View File

@@ -88,6 +88,7 @@ final class AppModel {
static let displayPolicy = "onyx.displayPolicy" static let displayPolicy = "onyx.displayPolicy"
static let showsMenuBarIcon = "onyx.showsMenuBarIcon" static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
static let showsDockIcon = "onyx.showsDockIcon" static let showsDockIcon = "onyx.showsDockIcon"
static let triggerArea = "onyx.triggerArea"
} }
init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) { init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) {
@@ -104,10 +105,25 @@ final class AppModel {
// zurück, wenn die Notch einmal nicht reagiert. // zurück, wenn die Notch einmal nicht reagiert.
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
self.showsDockIcon = defaults.object(forKey: Keys.showsDockIcon) as? Bool ?? false self.showsDockIcon = defaults.object(forKey: Keys.showsDockIcon) as? Bool ?? false
self.triggerArea = (defaults.data(forKey: Keys.triggerArea)
.flatMap { try? JSONDecoder().decode(TriggerArea.self, from: $0) }) ?? .standard
} }
// MARK: - Widgets // MARK: - Widgets
/// Die Auslösefläche der Notch. Wirkt sofort.
var triggerArea: TriggerArea {
didSet {
guard triggerArea != oldValue else { return }
if let data = try? JSONEncoder().encode(triggerArea) {
defaults.set(data, forKey: Keys.triggerArea)
}
onTriggerAreaChanged?(triggerArea)
}
}
var onTriggerAreaChanged: ((TriggerArea) -> Void)?
var panelContentSize: CGSize { var panelContentSize: CGSize {
WidgetGrid.panelSize(for: layout, availableWidth: Self.availableWidth) WidgetGrid.panelSize(for: layout, availableWidth: Self.availableWidth)
} }

View File

@@ -100,13 +100,13 @@ private final class MixerStatusView: NSView {
} }
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
// Ausgeschaltet bleibt es sichtbar, aber zurückgenommen: das Element // Volle Deckkraft, auch ausgeschaltet.
// verschwinden zu lassen wäre die schlechtere Antwort dann fände man //
// den Weg zum Einschalten nicht mehr. // Die Abdunklung war meine Idee und ein Fehlgriff: in der Menüleiste
let color = (isEnabled && managed > 0) // liegt oft ein heller Schreibtischhintergrund, und ein zu 55 % blasses
? NSColor.labelColor // Glyph ist dort praktisch weg. Ob der Mixer läuft, erfährt man beim
: NSColor.labelColor.withAlphaComponent(0.55) // Klick dafür muss man das Symbol aber erst finden.
MenuBarText.drawSymbol("slider.horizontal.3", color: color, MenuBarText.drawSymbol("slider.horizontal.3", color: .labelColor,
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5, in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
width: 15, height: 15)) width: 15, height: 15))
} }

View File

@@ -71,7 +71,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Neustart wieder ohne Dock-Symbol da, obwohl es eingeschaltet ist. // Neustart wieder ohne Dock-Symbol da, obwohl es eingeschaltet ist.
NSApp.setActivationPolicy(model.showsDockIcon ? .regular : .accessory) NSApp.setActivationPolicy(model.showsDockIcon ? .regular : .accessory)
let coordinator = NotchCoordinator(policy: model.displayPolicy) { _, presentation in let coordinator = NotchCoordinator(policy: model.displayPolicy,
trigger: model.triggerArea) { _, presentation in
NotchHostingView( NotchHostingView(
rootView: NotchContainer(presentation: presentation) { rootView: NotchContainer(presentation: presentation) {
NotchPanelContent(model: model) NotchPanelContent(model: model)
@@ -85,6 +86,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Einstellungen wirken sofort. Eine Änderung, die erst nach einem // Einstellungen wirken sofort. Eine Änderung, die erst nach einem
// Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar. // Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar.
model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() } model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() }
model.onTriggerAreaChanged = { [weak self] area in
self?.coordinator?.trigger = area
}
model.onDisplayPolicyChanged = { [weak self] policy in model.onDisplayPolicyChanged = { [weak self] policy in
self?.coordinator?.policy = policy self?.coordinator?.policy = policy
self?.applyContentSize() self?.applyContentSize()

View File

@@ -489,6 +489,12 @@ private struct DisplaySettings: View {
Divider() Divider()
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
// niemandem etwas, ein nachgebildeter Bildschirm schon.
TriggerAreaSettings(trigger: $model.triggerArea)
Divider()
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon) Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
Text("settings.display.menuBarIcon.hint") Text("settings.display.menuBarIcon.hint")
.font(.callout) .font(.callout)

View File

@@ -37,7 +37,8 @@ let package = Package(
.target(name: "OnyxDesign"), .target(name: "OnyxDesign"),
.target(name: "OnyxNotch", dependencies: ["OnyxDesign"]), .target(name: "OnyxNotch", dependencies: ["OnyxDesign"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]), .testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]),
.target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]), .target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]),

View File

@@ -49,12 +49,34 @@ public enum BatteryGlyph {
return isPluggedIn ? .plug : .none return isPluggedIn ? .plug : .none
} }
/// Wo der Blitz auf der Füllung liegt und wo im Leerraum.
///
/// Er wird über der Füllung **ausgestanzt** und daneben **ausgemalt**. Nur
/// auszustanzen reicht nicht: über dem leeren Teil ist das Transparenz auf
/// Transparenz, und bei wenig Ladung sitzt der Blitz fast ganz dort.
public static func boltSplit(charge: Double,
inner: CGRect) -> (filled: CGRect, empty: CGRect) {
let width = fillWidth(charge: charge, inner: inner.width)
let filled = CGRect(x: inner.minX, y: inner.minY,
width: width, height: inner.height)
let empty = CGRect(x: inner.minX + width, y: inner.minY,
width: inner.width - width, height: inner.height)
return (filled, empty)
}
public static func isLow(charge: Double, isCharging: Bool = false) -> Bool { public static func isLow(charge: Double, isCharging: Bool = false) -> Bool {
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das // Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
// sich schon erledigt. // sich schon erledigt.
!isCharging && charge < lowThreshold !isCharging && charge < lowThreshold
} }
/// Ein Symbol in der gewünschten Farbe. `color.set()` genügt nicht:
/// Template-Bilder tragen ihre Farbe nicht aus dem Grafikzustand.
private static func tint(_ image: NSImage, with color: NSColor) -> NSImage? {
let configuration = NSImage.SymbolConfiguration(paletteColors: [color])
return image.withSymbolConfiguration(configuration)
}
/// Zeichnet den Akku in `rect`. /// Zeichnet den Akku in `rect`.
/// ///
/// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei /// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei
@@ -105,9 +127,20 @@ public enum BatteryGlyph {
let glyphRect = CGRect(x: body.midX - glyphWidth / 2, let glyphRect = CGRect(x: body.midX - glyphWidth / 2,
y: rect.midY - glyphHeight / 2, y: rect.midY - glyphHeight / 2,
width: glyphWidth, height: glyphHeight) width: glyphWidth, height: glyphHeight)
// Geteilt zeichnen: über der Füllung ein Loch, daneben ein Glyph.
let split = boltSplit(charge: charge, inner: inner)
NSGraphicsContext.saveGraphicsState() NSGraphicsContext.saveGraphicsState()
NSBezierPath(rect: split.filled).setClip()
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1) glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
NSGraphicsContext.saveGraphicsState()
NSBezierPath(rect: split.empty).setClip()
if let tinted = tint(glyph, with: color) {
tinted.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
}
NSGraphicsContext.restoreGraphicsState()
} }
} }
@@ -155,11 +188,31 @@ public struct BatteryGlyphView: View {
} }
.frame(width: (BatteryGlyph.size.width - 3) * scale, height: height) .frame(width: (BatteryGlyph.size.width - 3) * scale, height: height)
.overlay { .overlay {
// Geteilt wie in der Menüleiste: über der Füllung ein Loch,
// daneben ein ausgemaltes Glyph. Nur auszustanzen hieße, dass
// der Blitz bei wenig Ladung im Leeren verschwindet.
if let name = BatteryGlyph.indicator(isCharging: isCharging, if let name = BatteryGlyph.indicator(isCharging: isCharging,
isPluggedIn: isPluggedIn).symbolName { isPluggedIn: isPluggedIn).symbolName {
Image(systemName: name) GeometryReader { geometry in
let inner = CGRect(origin: .zero, size: geometry.size)
.insetBy(dx: 2 * scale, dy: 2 * scale)
let split = BatteryGlyph.boltSplit(charge: charge, inner: inner)
let glyph = Image(systemName: name)
.font(.system(size: height * 0.68)) .font(.system(size: height * 0.68))
.blendMode(.destinationOut) .frame(width: geometry.size.width, height: geometry.size.height)
glyph.blendMode(.destinationOut)
.mask(alignment: .topLeading) {
Rectangle().frame(width: split.filled.width,
height: geometry.size.height)
.offset(x: split.filled.minX)
}
glyph.mask(alignment: .topLeading) {
Rectangle().frame(width: split.empty.width,
height: geometry.size.height)
.offset(x: split.empty.minX)
}
}
} }
} }
.compositingGroup() .compositingGroup()

View File

@@ -113,23 +113,29 @@ final class MetricStatusView: NSView, WidthReporting {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } required init?(coder: NSCoder) { fatalError() }
private var adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth)
private var shownWidth: CGFloat = MenuBarText.minimumWidth
override var intrinsicContentSize: NSSize { override var intrinsicContentSize: NSSize {
NSSize(width: width, height: 22) NSSize(width: shownWidth, height: 22)
} }
/// Gemessen statt geschätzt und zwar an der **breitesten Zeichenfolge, /// Der Platz für das, was **gerade** dasteht.
/// die vorkommen kann**, nicht an der gerade angezeigten. Sonst schiebt ///
/// jedes Prozent alle Symbole rechts davon hin und her. /// Auf die breiteste denkbare Zeichenfolge zu dimensionieren war zu teuer:
/// Temp 53°" stand in einer Box für Temp 100 °C", und in der Menüleiste
/// ist Platz das knappste Gut. `AdaptiveWidth` nimmt dem die Unruhe
/// sofort wachsen, zögernd schrumpfen.
private var width: CGFloat { private var width: CGFloat {
switch presentation { switch presentation {
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4) case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
case .graph: 28 case .graph: 28
case .bars: barsWidth case .bars: barsWidth
case .value: MenuBarText.width(for: reference) case .value: MenuBarText.width(for: displayValue)
case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28 case .valueAndGraph: MenuBarText.width(for: displayValue) + 2 + 28
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)") case .labelAndValue: MenuBarText.width(for: "\(label) \(displayValue)")
case .symbolAndValue: case .symbolAndValue:
MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap MenuBarText.width(for: displayValue) + symbolSlot + MenuBarText.innerGap
} }
} }
@@ -150,13 +156,6 @@ final class MetricStatusView: NSView, WidthReporting {
return MetricSummary.value(metric, snapshot, mode: mode) return MetricSummary.value(metric, snapshot, mode: mode)
} }
/// Die breiteste Zeichenfolge dieser Metrik.
private var reference: String {
switch (metric, mode) {
case (.temperature, _), (_, .temperature): "100 °C"
default: "100 %"
}
}
/// Meldet, wenn sich die nötige Breite geändert hat der Controller stellt /// Meldet, wenn sich die nötige Breite geändert hat der Controller stellt
/// das Element danach neu ein. /// das Element danach neu ein.
@@ -166,17 +165,16 @@ final class MetricStatusView: NSView, WidthReporting {
var sensorCelsius: Double? var sensorCelsius: Double?
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) { func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
let before = width
self.snapshot = snapshot self.snapshot = snapshot
self.history = history self.history = history
self.mode = mode self.mode = mode
needsDisplay = true needsDisplay = true
// Die Kernzahl steht erst nach der ersten Messung fest, und der Modus
// wechselt zwischen 100 %" und 100 °C". let next = adaptive.update(needed: width)
if width != before { guard next != shownWidth else { return }
shownWidth = next
invalidateIntrinsicContentSize() invalidateIntrinsicContentSize()
onWidthChange?(width) onWidthChange?(next)
}
} }
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {

View File

@@ -0,0 +1,70 @@
{
"sourceLanguage": "en",
"strings": {
"trigger.height %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Höhe, %lld pt nach unten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Height, %lld pt below"
}
}
}
},
"trigger.hint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wie weit über die Notch hinaus der Zeiger das Panel öffnet. Breiter trifft man leichter; schmaler hält das Panel aus dem Weg, während man die Menüleiste benutzt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "How far past the notch the pointer opens the panel. Wider is easier to hit; narrower keeps the panel out of the way while you use the menu bar."
}
}
}
},
"trigger.reset": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Auf Standard zurücksetzen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Reset to default"
}
}
}
},
"trigger.width %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Breite, %lld pt je Seite"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Width, %lld pt per side"
}
}
}
}
},
"version": "1.1"
}

View File

@@ -20,6 +20,14 @@ private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "No
@MainActor @MainActor
public final class NotchCoordinator { public final class NotchCoordinator {
/// Die Auslösefläche für alle Bildschirme. Ändern wirkt sofort.
public var trigger: TriggerArea = .standard {
didSet {
guard trigger != oldValue else { return }
controllers.values.forEach { $0.trigger = trigger }
}
}
public var policy: DisplayPolicy { public var policy: DisplayPolicy {
didSet { guard policy != oldValue else { return }; rebuild() } didSet { guard policy != oldValue else { return }; rebuild() }
} }
@@ -41,9 +49,11 @@ public final class NotchCoordinator {
private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView
public init(policy: DisplayPolicy = .builtInOnly, public init(policy: DisplayPolicy = .builtInOnly,
trigger: TriggerArea = .standard,
virtualNotchSize: CGSize = CGSize(width: 220, height: 32), virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) { makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
self.policy = policy self.policy = policy
self.trigger = trigger
self.virtualNotchSize = virtualNotchSize self.virtualNotchSize = virtualNotchSize
self.makeContent = makeContent self.makeContent = makeContent
} }
@@ -104,6 +114,7 @@ public final class NotchCoordinator {
let controller = NotchScreenController( let controller = NotchScreenController(
metrics: metrics, metrics: metrics,
virtualSize: virtualNotchSize, virtualSize: virtualNotchSize,
trigger: trigger,
makeContent: { presentation in makeContent(id, presentation) }) makeContent: { presentation in makeContent(id, presentation) })
controller.onVisibilityChanged = { [weak self] _ in controller.onVisibilityChanged = { [weak self] _ in
self?.updatePolling() self?.updatePolling()

View File

@@ -70,22 +70,31 @@ public struct NotchGeometry: Equatable, Sendable {
public let rect: CGRect public let rect: CGRect
public let screenFrame: CGRect public let screenFrame: CGRect
/// Wie weit die Auslösefläche über die Notch hinausreicht. Ohne diesen Rand /// Wie weit die Auslösefläche über die Notch hinausreicht einstellbar,
/// muss man die Notch pixelgenau treffen, und die unteren Bildpunkte der /// weil es an Hand und Zeigegerät hängt. Siehe ``TriggerArea``.
/// Notch selbst nimmt die Menüleiste bereits für sich in Anspruch. public var trigger: TriggerArea = .standard
/// Nur noch für Bestandscode: die frühere feste Zugabe.
public static let triggerInsetX: CGFloat = 16 public static let triggerInsetX: CGFloat = 16
/// Nach unten deutlich großzügiger als nach oben: von oben kommt niemand, /// Nach unten deutlich großzügiger als nach oben: von oben kommt niemand,
/// dort ist der Bildschirmrand. Vier Punkte waren zu wenig eine Hand /// dort ist der Bildschirmrand. Vier Punkte waren zu wenig eine Hand
/// hält den Zeiger nicht auf vier Punkte genau. /// hält den Zeiger nicht auf vier Punkte genau.
public static let triggerInsetY: CGFloat = 14 public static let triggerInsetY: CGFloat = 14
func with(trigger: TriggerArea) -> NotchGeometry {
var copy = self
copy.trigger = trigger
return copy
}
public init(kind: Kind, rect: CGRect, screenFrame: CGRect) { public init(kind: Kind, rect: CGRect, screenFrame: CGRect) {
self.kind = kind self.kind = kind
self.rect = rect self.rect = rect
self.screenFrame = screenFrame self.screenFrame = screenFrame
} }
public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize) -> NotchGeometry { public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize,
trigger: TriggerArea = .standard) -> NotchGeometry {
// Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am // Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am
// Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt, // Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt,
// obwohl es nichts gibt, um das man herumlegen könnte. // obwohl es nichts gibt, um das man herumlegen könnte.
@@ -100,7 +109,7 @@ public struct NotchGeometry: Equatable, Sendable {
y: metrics.frame.maxY - metrics.safeAreaTop, y: metrics.frame.maxY - metrics.safeAreaTop,
width: width, width: width,
height: metrics.safeAreaTop), height: metrics.safeAreaTop),
screenFrame: metrics.frame) screenFrame: metrics.frame).with(trigger: trigger)
} }
} }
@@ -111,14 +120,12 @@ public struct NotchGeometry: Equatable, Sendable {
y: metrics.frame.maxY - virtualSize.height, y: metrics.frame.maxY - virtualSize.height,
width: width, width: width,
height: virtualSize.height), height: virtualSize.height),
screenFrame: metrics.frame) screenFrame: metrics.frame).with(trigger: trigger)
} }
/// Die Fläche, deren Betreten das Panel scharf schaltet. /// Die Fläche, deren Betreten das Panel scharf schaltet.
public var triggerRect: CGRect { public var triggerRect: CGRect {
rect trigger.rect(notch: rect, screen: screenFrame)
.insetBy(dx: -Self.triggerInsetX, dy: -Self.triggerInsetY)
.clamped(to: screenFrame)
} }
/// Der Bereich, in dem Inhalt tatsächlich zu sehen ist. /// Der Bereich, in dem Inhalt tatsächlich zu sehen ist.

View File

@@ -38,12 +38,23 @@ public final class NotchScreenController {
CGSize(width: contentSize.width, height: contentSize.height + geometry.rect.height) CGSize(width: contentSize.width, height: contentSize.height + geometry.rect.height)
} }
/// Die eingestellte Auslösefläche. Ändern wirkt sofort.
public var trigger: TriggerArea {
didSet {
guard trigger != oldValue else { return }
geometry.trigger = trigger
}
}
public init(metrics: ScreenMetrics, public init(metrics: ScreenMetrics,
virtualSize: CGSize, virtualSize: CGSize,
trigger: TriggerArea = .standard,
contentSize: CGSize = CGSize(width: 640, height: 320), contentSize: CGSize = CGSize(width: 640, height: 320),
makeContent: (NotchPresentation) -> NSView) { makeContent: (NotchPresentation) -> NSView) {
self.displayID = metrics.displayID self.displayID = metrics.displayID
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) self.trigger = trigger
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
trigger: trigger)
self.geometry = geometry self.geometry = geometry
self.contentSize = contentSize self.contentSize = contentSize
self.presentation = NotchPresentation( self.presentation = NotchPresentation(
@@ -103,7 +114,8 @@ public final class NotchScreenController {
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung. /// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
public func update(metrics: ScreenMetrics, virtualSize: CGSize) { public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
trigger: trigger)
presentation.collapsedSize = geometry.rect.size presentation.collapsedSize = geometry.rect.size
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem // Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
// (virtueller Balken) ändert sich der Freiraum mit. // (virtueller Balken) ändert sich der Freiraum mit.

View File

@@ -0,0 +1,39 @@
import Foundation
import CoreGraphics
/// Wie weit die Auslösefläche über die Notch hinausreicht.
///
/// Feste Werte im Code waren dafür die falsche Antwort: wie genau jemand die
/// Notch trifft, hängt an der Hand und am Zeigegerät. Wer mit dem Trackpad
/// grob nach oben fährt, will eine großzügige Fläche; wer die Menüleiste viel
/// benutzt, will sie knapp, damit das Panel nicht dauernd aufgeht.
public struct TriggerArea: Codable, Equatable, Sendable {
/// Zugabe je Seite.
public let extraWidth: CGFloat
/// Zugabe nach unten.
public let extraHeight: CGFloat
public static let maximumExtraWidth: CGFloat = 200
public static let maximumExtraHeight: CGFloat = 120
/// Was bisher fest im Code stand. Wer nichts einstellt, merkt nichts.
public static let standard = TriggerArea(extraWidth: 16, extraHeight: 14)
public init(extraWidth: CGFloat, extraHeight: CGFloat) {
self.extraWidth = min(max(extraWidth, 0), Self.maximumExtraWidth)
self.extraHeight = min(max(extraHeight, 0), Self.maximumExtraHeight)
}
/// Die Fläche, deren Betreten das Panel scharf schaltet.
///
/// Zur Seite wächst sie symmetrisch, nach unten einseitig nach oben ist
/// der Bildschirmrand, von dort kommt niemand.
public func rect(notch: CGRect, screen: CGRect) -> CGRect {
CGRect(x: notch.minX - extraWidth,
y: notch.minY - extraHeight,
width: notch.width + 2 * extraWidth,
height: notch.height + extraHeight)
.intersection(screen)
}
}

View File

@@ -0,0 +1,122 @@
import SwiftUI
import OnyxDesign
/// Einstellung der Auslösefläche mit Vorschau.
///
/// Zwei Zahlen in Punkten sagen niemandem etwas: 24 Punkte unter der Notch"
/// ist keine Vorstellung, die man im Kopf hat. Ein nachgebildeter Bildschirm
/// mit der Notch und der eingefärbten Fläche darüber schon man sieht sofort,
/// wie weit man greifen muss.
public struct TriggerAreaSettings: View {
@Binding private var trigger: TriggerArea
public init(trigger: Binding<TriggerArea>) {
self._trigger = trigger
}
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("trigger.hint", bundle: .module)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
TriggerPreview(trigger: trigger)
.frame(height: 150)
.frame(maxWidth: .infinity)
LabeledContent {
Slider(value: Binding(
get: { trigger.extraWidth },
set: { trigger = TriggerArea(extraWidth: $0,
extraHeight: trigger.extraHeight) }),
in: 0...TriggerArea.maximumExtraWidth)
} label: {
Text("trigger.width \(Int(trigger.extraWidth))", bundle: .module)
.monospacedDigit()
}
LabeledContent {
Slider(value: Binding(
get: { trigger.extraHeight },
set: { trigger = TriggerArea(extraWidth: trigger.extraWidth,
extraHeight: $0) }),
in: 0...TriggerArea.maximumExtraHeight)
} label: {
Text("trigger.height \(Int(trigger.extraHeight))", bundle: .module)
.monospacedDigit()
}
Button {
trigger = .standard
} label: {
Text("trigger.reset", bundle: .module)
}
.buttonStyle(.borderless)
}
}
}
/// Der nachgebildete Bildschirm.
///
/// Absichtlich karg: es geht nur darum, wo die Notch sitzt und wie weit die
/// Fläche darum herum reicht. Ein Schreibtischhintergrund oder Fenster darin
/// wären Beiwerk, das von der einen Frage ablenkt.
private struct TriggerPreview: View {
let trigger: TriggerArea
/// Maße eines MacBook Pro mit Notch, auf die Vorschau heruntergerechnet.
private let screenAspect: CGFloat = 1800 / 1169
private let notchWidthFraction: CGFloat = 220 / 1800
private let notchHeightFraction: CGFloat = 38 / 1169
var body: some View {
GeometryReader { geometry in
let height = min(geometry.size.height, geometry.size.width / screenAspect)
let width = height * screenAspect
let scale = width / 1800
let notchWidth = width * notchWidthFraction
let notchHeight = height * notchHeightFraction
let areaWidth = notchWidth + 2 * trigger.extraWidth * scale
let areaHeight = notchHeight + trigger.extraHeight * scale
ZStack(alignment: .top) {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Onyx.Color.surface)
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
}
// Die Menüleiste als dünner Streifen: sie erklärt, warum die
// Fläche nicht beliebig breit werden sollte.
VStack(spacing: 0) {
Rectangle()
.fill(.white.opacity(0.06))
.frame(height: notchHeight)
Spacer(minLength: 0)
}
// Die Auslösefläche.
Rectangle()
.fill(Onyx.Color.accent.opacity(0.3))
.overlay {
Rectangle().strokeBorder(Onyx.Color.accent, lineWidth: 1)
}
.frame(width: areaWidth, height: areaHeight)
// Die Notch selbst undurchsichtig, wie in echt.
UnevenRoundedRectangle(bottomLeadingRadius: 5, bottomTrailingRadius: 5,
style: .continuous)
.fill(.black)
.frame(width: notchWidth, height: notchHeight)
}
.frame(width: width, height: height)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(Onyx.Motion.value, value: areaWidth)
.animation(Onyx.Motion.value, value: areaHeight)
}
}
}

View File

@@ -102,3 +102,56 @@ struct BatteryIndicatorTests {
} }
} }
} }
// Der Blitz ist ein Loch in der Füllung. Über dem gefüllten Teil liest man ihn,
// über dem leeren ist es Transparenz auf Transparenz bei wenig Ladung sitzt
// er fast ganz im Leeren und verschwindet.
//
// Also geteilt zeichnen: über der Füllung ausgestanzt, daneben ausgemalt.
@Suite("Blitz über Füllung und Leerraum")
struct BatteryBoltSplitTests {
private let inner = CGRect(x: 10, y: 0, width: 20, height: 8)
@Test("Bei voller Ladung liegt alles auf der Füllung")
func fullIsAllFilled() {
let split = BatteryGlyph.boltSplit(charge: 1, inner: inner)
#expect(split.filled == inner)
#expect(split.empty.width == 0)
}
@Test("Bei leerem Akku liegt alles im Leerraum")
func emptyIsAllEmpty() {
let split = BatteryGlyph.boltSplit(charge: 0, inner: inner)
#expect(split.filled.width == 0)
#expect(split.empty == inner)
}
@Test("Dazwischen teilt sich der Blitz an der Kante der Füllung")
func splitFollowsFill() {
let split = BatteryGlyph.boltSplit(charge: 0.5, inner: inner)
#expect(split.filled.maxX == split.empty.minX)
#expect(split.filled.width + split.empty.width == inner.width)
}
@Test("Die beiden Teile stoßen aneinander, statt sich zu überlappen")
func partsMeetWithoutOverlap() {
// Sonst wird der Blitz an der Nahtstelle doppelt gezeichnet und sieht
// dort dicker aus. Geprüft wird die Kante selbst und nicht
// `intersects`: das meldet bei einem Teil der Breite null trotzdem
// eine Überschneidung.
for percent in stride(from: 0.0, through: 1.0, by: 0.05) {
let split = BatteryGlyph.boltSplit(charge: percent, inner: inner)
#expect(split.filled.maxX <= split.empty.minX + 0.001)
}
}
@Test("Zusammen decken sie den ganzen Innenraum")
func partsCoverEverything() {
for percent in stride(from: 0.0, through: 1.0, by: 0.05) {
let split = BatteryGlyph.boltSplit(charge: percent, inner: inner)
#expect(abs(split.filled.width + split.empty.width - inner.width) < 0.001)
}
}
}

View File

@@ -0,0 +1,72 @@
import Testing
import CoreGraphics
import Foundation
@testable import OnyxNotch
// Wie weit die Auslösefläche über die Notch hinausreicht, ist Geschmackssache
// und hängt an der Hand: manche zielen genau, andere fahren nur grob nach oben.
// Feste Werte im Code sind dafür die falsche Antwort.
@Suite("Auslösefläche")
struct TriggerAreaTests {
private let notch = CGRect(x: 790, y: 1131, width: 220, height: 38)
private let screen = CGRect(x: 0, y: 0, width: 1800, height: 1169)
@Test("Ohne Zugabe ist die Fläche die Notch selbst")
func zeroMeansNotchOnly() {
let area = TriggerArea(extraWidth: 0, extraHeight: 0)
#expect(area.rect(notch: notch, screen: screen) == notch)
}
@Test("Die Breite wächst nach beiden Seiten")
func widthGrowsBothWays() {
// Sonst säße die Fläche außermittig, und man träfe sie links anders
// als rechts.
let rect = TriggerArea(extraWidth: 30, extraHeight: 0)
.rect(notch: notch, screen: screen)
#expect(rect.minX == notch.minX - 30)
#expect(rect.maxX == notch.maxX + 30)
}
@Test("Die Höhe wächst nur nach unten")
func heightGrowsDownwards() {
// Nach oben ist der Bildschirmrand dort kommt niemand her.
let rect = TriggerArea(extraWidth: 0, extraHeight: 25)
.rect(notch: notch, screen: screen)
#expect(rect.maxY == notch.maxY)
#expect(rect.minY == notch.minY - 25)
}
@Test("Die Fläche bleibt auf dem Bildschirm")
func staysOnScreen() {
let rect = TriggerArea(extraWidth: 5000, extraHeight: 5000)
.rect(notch: notch, screen: screen)
#expect(rect.minX >= screen.minX)
#expect(rect.maxX <= screen.maxX)
#expect(rect.minY >= screen.minY)
}
@Test("Werte außerhalb des Erlaubten werden eingefangen")
func valuesAreClamped() {
#expect(TriggerArea(extraWidth: -50, extraHeight: -50).extraWidth == 0)
#expect(TriggerArea(extraWidth: 9999, extraHeight: 0).extraWidth
== TriggerArea.maximumExtraWidth)
#expect(TriggerArea(extraWidth: 0, extraHeight: 9999).extraHeight
== TriggerArea.maximumExtraHeight)
}
@Test("Die Voreinstellung ist die, mit der bisher gearbeitet wurde")
func defaultMatchesPreviousBehaviour() {
// Wer nichts einstellt, soll nichts merken.
#expect(TriggerArea.standard.extraWidth == 16)
#expect(TriggerArea.standard.extraHeight == 14)
}
@Test("Gespeichert und gelesen ergibt dasselbe")
func roundTrips() throws {
let area = TriggerArea(extraWidth: 42, extraHeight: 7)
let data = try JSONEncoder().encode(area)
#expect(try JSONDecoder().decode(TriggerArea.self, from: data) == area)
}
}