diff --git a/Onyx/AppIcon.icon/Assets/quartz.svg b/Onyx/AppIcon.icon/Assets/quartz (2).svg
similarity index 77%
rename from Onyx/AppIcon.icon/Assets/quartz.svg
rename to Onyx/AppIcon.icon/Assets/quartz (2).svg
index 736677b..999318f 100644
--- a/Onyx/AppIcon.icon/Assets/quartz.svg
+++ b/Onyx/AppIcon.icon/Assets/quartz (2).svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/Onyx/AppIcon.icon/Assets/quartz (4).svg b/Onyx/AppIcon.icon/Assets/quartz (4).svg
new file mode 100644
index 0000000..2eed112
--- /dev/null
+++ b/Onyx/AppIcon.icon/Assets/quartz (4).svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Onyx/AppIcon.icon/icon.json b/Onyx/AppIcon.icon/icon.json
index 0ff68ad..1be1689 100644
--- a/Onyx/AppIcon.icon/icon.json
+++ b/Onyx/AppIcon.icon/icon.json
@@ -2,13 +2,13 @@
"fill-specializations" : [
{
"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",
"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" : [
{
- "image-name" : "quartz.svg",
- "name" : "quartz",
+ "hidden" : false,
+ "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" : {
"scale" : 1.39,
"translation-in-points" : [
diff --git a/Onyx/AppModel.swift b/Onyx/AppModel.swift
index f991de5..c188001 100644
--- a/Onyx/AppModel.swift
+++ b/Onyx/AppModel.swift
@@ -88,6 +88,7 @@ final class AppModel {
static let displayPolicy = "onyx.displayPolicy"
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
static let showsDockIcon = "onyx.showsDockIcon"
+ static let triggerArea = "onyx.triggerArea"
}
init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) {
@@ -104,10 +105,25 @@ final class AppModel {
// zurück, wenn die Notch einmal nicht reagiert.
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
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
+ /// 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 {
WidgetGrid.panelSize(for: layout, availableWidth: Self.availableWidth)
}
diff --git a/Onyx/MixerMenuBarModule.swift b/Onyx/MixerMenuBarModule.swift
index d59bba0..e96f29d 100644
--- a/Onyx/MixerMenuBarModule.swift
+++ b/Onyx/MixerMenuBarModule.swift
@@ -100,13 +100,13 @@ private final class MixerStatusView: NSView {
}
override func draw(_ dirtyRect: NSRect) {
- // Ausgeschaltet bleibt es sichtbar, aber zurückgenommen: das Element
- // verschwinden zu lassen wäre die schlechtere Antwort — dann fände man
- // den Weg zum Einschalten nicht mehr.
- let color = (isEnabled && managed > 0)
- ? NSColor.labelColor
- : NSColor.labelColor.withAlphaComponent(0.55)
- MenuBarText.drawSymbol("slider.horizontal.3", color: color,
+ // Volle Deckkraft, auch ausgeschaltet.
+ //
+ // Die Abdunklung war meine Idee und ein Fehlgriff: in der Menüleiste
+ // liegt oft ein heller Schreibtischhintergrund, und ein zu 55 % blasses
+ // Glyph ist dort praktisch weg. Ob der Mixer läuft, erfährt man beim
+ // Klick — dafür muss man das Symbol aber erst finden.
+ MenuBarText.drawSymbol("slider.horizontal.3", color: .labelColor,
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
width: 15, height: 15))
}
diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift
index d7191ed..150c377 100644
--- a/Onyx/OnyxApp.swift
+++ b/Onyx/OnyxApp.swift
@@ -71,7 +71,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Neustart wieder ohne Dock-Symbol da, obwohl es eingeschaltet ist.
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(
rootView: NotchContainer(presentation: presentation) {
NotchPanelContent(model: model)
@@ -85,6 +86,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Einstellungen wirken sofort. Eine Änderung, die erst nach einem
// Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar.
model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() }
+ model.onTriggerAreaChanged = { [weak self] area in
+ self?.coordinator?.trigger = area
+ }
model.onDisplayPolicyChanged = { [weak self] policy in
self?.coordinator?.policy = policy
self?.applyContentSize()
diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift
index 5f58a0f..74b52da 100644
--- a/Onyx/SettingsView.swift
+++ b/Onyx/SettingsView.swift
@@ -489,6 +489,12 @@ private struct DisplaySettings: View {
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)
Text("settings.display.menuBarIcon.hint")
.font(.callout)
diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift
index 29f928d..4ac8ba9 100644
--- a/Packages/OnyxKit/Package.swift
+++ b/Packages/OnyxKit/Package.swift
@@ -37,7 +37,8 @@ let package = Package(
.target(name: "OnyxDesign"),
- .target(name: "OnyxNotch", dependencies: ["OnyxDesign"]),
+ .target(name: "OnyxNotch", dependencies: ["OnyxDesign"],
+ resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]),
.target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]),
diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
index 2d44c3b..5f6ceb4 100644
--- a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
+++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
@@ -49,12 +49,34 @@ public enum BatteryGlyph {
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 {
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
// sich schon erledigt.
!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`.
///
/// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei
@@ -105,9 +127,20 @@ public enum BatteryGlyph {
let glyphRect = CGRect(x: body.midX - glyphWidth / 2,
y: rect.midY - glyphHeight / 2,
width: glyphWidth, height: glyphHeight)
+ // Geteilt zeichnen: über der Füllung ein Loch, daneben ein Glyph.
+ let split = boltSplit(charge: charge, inner: inner)
+
NSGraphicsContext.saveGraphicsState()
+ NSBezierPath(rect: split.filled).setClip()
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
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)
.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,
isPluggedIn: isPluggedIn).symbolName {
- Image(systemName: name)
- .font(.system(size: height * 0.68))
- .blendMode(.destinationOut)
+ 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))
+ .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()
diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
index 4aa8033..8bb252b 100644
--- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
+++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift
@@ -113,23 +113,29 @@ final class MetricStatusView: NSView, WidthReporting {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
+ private var adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth)
+ private var shownWidth: CGFloat = MenuBarText.minimumWidth
+
override var intrinsicContentSize: NSSize {
- NSSize(width: width, height: 22)
+ NSSize(width: shownWidth, height: 22)
}
- /// Gemessen statt geschätzt — und zwar an der **breitesten Zeichenfolge,
- /// die vorkommen kann**, nicht an der gerade angezeigten. Sonst schiebt
- /// jedes Prozent alle Symbole rechts davon hin und her.
+ /// Der Platz für das, was **gerade** dasteht.
+ ///
+ /// 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 {
switch presentation {
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
case .graph: 28
case .bars: barsWidth
- case .value: MenuBarText.width(for: reference)
- case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28
- case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
+ case .value: MenuBarText.width(for: displayValue)
+ case .valueAndGraph: MenuBarText.width(for: displayValue) + 2 + 28
+ case .labelAndValue: MenuBarText.width(for: "\(label) \(displayValue)")
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)
}
- /// 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
/// das Element danach neu ein.
@@ -166,17 +165,16 @@ final class MetricStatusView: NSView, WidthReporting {
var sensorCelsius: Double?
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
- let before = width
self.snapshot = snapshot
self.history = history
self.mode = mode
needsDisplay = true
- // Die Kernzahl steht erst nach der ersten Messung fest, und der Modus
- // wechselt zwischen „100 %" und „100 °C".
- if width != before {
- invalidateIntrinsicContentSize()
- onWidthChange?(width)
- }
+
+ let next = adaptive.update(needed: width)
+ guard next != shownWidth else { return }
+ shownWidth = next
+ invalidateIntrinsicContentSize()
+ onWidthChange?(next)
}
override func draw(_ dirtyRect: NSRect) {
diff --git a/Packages/OnyxKit/Sources/OnyxNotch/Localizable.xcstrings b/Packages/OnyxKit/Sources/OnyxNotch/Localizable.xcstrings
new file mode 100644
index 0000000..2824b18
--- /dev/null
+++ b/Packages/OnyxKit/Sources/OnyxNotch/Localizable.xcstrings
@@ -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"
+}
diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
index 765b3b9..d839842 100644
--- a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
+++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
@@ -20,6 +20,14 @@ private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "No
@MainActor
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 {
didSet { guard policy != oldValue else { return }; rebuild() }
}
@@ -41,9 +49,11 @@ public final class NotchCoordinator {
private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView
public init(policy: DisplayPolicy = .builtInOnly,
+ trigger: TriggerArea = .standard,
virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
self.policy = policy
+ self.trigger = trigger
self.virtualNotchSize = virtualNotchSize
self.makeContent = makeContent
}
@@ -104,6 +114,7 @@ public final class NotchCoordinator {
let controller = NotchScreenController(
metrics: metrics,
virtualSize: virtualNotchSize,
+ trigger: trigger,
makeContent: { presentation in makeContent(id, presentation) })
controller.onVisibilityChanged = { [weak self] _ in
self?.updatePolling()
diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift
index d785d25..2f149fa 100644
--- a/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift
+++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift
@@ -70,22 +70,31 @@ public struct NotchGeometry: Equatable, Sendable {
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.
+ /// Wie weit die Auslösefläche über die Notch hinausreicht — einstellbar,
+ /// weil es an Hand und Zeigegerät hängt. Siehe ``TriggerArea``.
+ public var trigger: TriggerArea = .standard
+
+ /// Nur noch für Bestandscode: die frühere feste Zugabe.
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
+ func with(trigger: TriggerArea) -> NotchGeometry {
+ var copy = self
+ copy.trigger = trigger
+ return copy
+ }
+
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 {
+ 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
// Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt,
// 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,
width: width,
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,
width: width,
height: virtualSize.height),
- screenFrame: metrics.frame)
+ screenFrame: metrics.frame).with(trigger: trigger)
}
/// Die Fläche, deren Betreten das Panel scharf schaltet.
public var triggerRect: CGRect {
- rect
- .insetBy(dx: -Self.triggerInsetX, dy: -Self.triggerInsetY)
- .clamped(to: screenFrame)
+ trigger.rect(notch: rect, screen: screenFrame)
}
/// Der Bereich, in dem Inhalt tatsächlich zu sehen ist.
diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift
index 51fdbaf..aa0a9f8 100644
--- a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift
+++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift
@@ -38,12 +38,23 @@ public final class NotchScreenController {
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,
virtualSize: CGSize,
+ trigger: TriggerArea = .standard,
contentSize: CGSize = CGSize(width: 640, height: 320),
makeContent: (NotchPresentation) -> NSView) {
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.contentSize = contentSize
self.presentation = NotchPresentation(
@@ -103,7 +114,8 @@ public final class NotchScreenController {
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
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
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
// (virtueller Balken) ändert sich der Freiraum mit.
diff --git a/Packages/OnyxKit/Sources/OnyxNotch/TriggerArea.swift b/Packages/OnyxKit/Sources/OnyxNotch/TriggerArea.swift
new file mode 100644
index 0000000..ab46e4e
--- /dev/null
+++ b/Packages/OnyxKit/Sources/OnyxNotch/TriggerArea.swift
@@ -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)
+ }
+}
diff --git a/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift b/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift
new file mode 100644
index 0000000..b877ec7
--- /dev/null
+++ b/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift
@@ -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) {
+ 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)
+ }
+ }
+}
diff --git a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift
index c150ca2..951a6b8 100644
--- a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift
+++ b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift
@@ -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)
+ }
+ }
+}
diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/TriggerAreaTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/TriggerAreaTests.swift
new file mode 100644
index 0000000..dd78fd2
--- /dev/null
+++ b/Packages/OnyxKit/Tests/OnyxNotchTests/TriggerAreaTests.swift
@@ -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)
+ }
+}