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) + } +}