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:
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
70
Packages/OnyxKit/Sources/OnyxNotch/Localizable.xcstrings
Normal file
70
Packages/OnyxKit/Sources/OnyxNotch/Localizable.xcstrings
Normal 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"
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
39
Packages/OnyxKit/Sources/OnyxNotch/TriggerArea.swift
Normal file
39
Packages/OnyxKit/Sources/OnyxNotch/TriggerArea.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
122
Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift
Normal file
122
Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user