diff --git a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings index 7c7e62c..4c2b49a 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings @@ -1,137 +1,143 @@ { - "sourceLanguage": "en", - "strings": { - "": {}, - "%lld %%": { - "comment": "A row of controls for adjusting the volume and muting a process.", - "isCommentAutoGenerated": true, - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld %%" + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "–" : { + "comment" : "A placeholder for the volume of a process.", + "isCommentAutoGenerated" : true + }, + "%lld %%" : { + "comment" : "A row of controls for adjusting the volume and muting a process.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %%" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld %%" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %%" } } } }, - "mixer.name": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Audio-Mixer" + "mixer.name" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio-Mixer" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Audio mixer" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio mixer" } } } }, - "mixer.noProcesses": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Kein Programm gibt gerade Ton aus" + "mixer.noProcesses" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Programm gibt gerade Ton aus" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No app is playing audio" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No app is playing audio" } } } }, - "mixer.off": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mixer ausschalten" + "mixer.off" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mixer ausschalten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Turn mixer off" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn mixer off" } } } }, - "mixer.off.explain": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lautstärke einzelner Programme regeln, auch über 100 %." + "mixer.off.explain" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lautstärke einzelner Programme regeln, auch über 100 %." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Set the volume of individual apps, including above 100 %." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set the volume of individual apps, including above 100 %." } } } }, - "mixer.off.warning": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt." + "mixer.off.warning" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately." } } } }, - "mixer.on": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mixer einschalten" + "mixer.on" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mixer einschalten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Turn mixer on" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn mixer on" } } } }, - "mixer.release": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nicht mehr regeln" + "mixer.release" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht mehr regeln" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Stop controlling" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop controlling" } } } } }, - "version": "1.1" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift index 8fb6b11..765b3b9 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift @@ -160,7 +160,7 @@ public final class NotchCoordinator { for controller in controllers.values where controller.isVisible { if controller.geometry.triggerRect.contains(point) { controller.click() - } else if !controller.panelFrame.contains(point) { + } else if !controller.containsVisiblePoint(point) { controller.clickedOutside() } } diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift index 594abaa..3452270 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift @@ -28,12 +28,75 @@ public final class NotchPresentation { self.contentTopInset = contentTopInset } + /// Die Fläche, die das Panel oben einnehmen darf, ohne die Menüleiste + /// zuzudecken — also die Breite der Notch. + /// + /// Darunter darf es beliebig breit werden; dort ist keine Menüleiste mehr. + public var notchWidth: CGFloat { collapsedSize.width } + /// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf. /// Muss zu `Onyx.Motion.collapse` passen: zu früh entfernt reißt das Panel /// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen. public static let collapseDuration: TimeInterval = 0.30 } +/// Die Silhouette des Panels: oben nur so breit wie die Notch, darunter voll. +/// +/// **Warum nicht einfach ein Rechteck.** Das Panel reicht bis an die +/// Bildschirmoberkante, damit seine Fläche mit der schwarzen Notch zu einem +/// Körper verschmilzt. Sobald es breiter wird als die Notch, deckt es links +/// und rechts davon aber die Menüleiste zu — bei sechs Kacheln die ganze, +/// samt aller Statuselemente. +/// +/// Also ein T: oben ein Steg in Notch-Breite, ab Unterkante der Menüleiste die +/// volle Fläche. Die Notch bleibt verschmolzen, die Menüleiste bleibt frei. +struct NotchShape: Shape, InsettableShape { + var cornerRadius: CGFloat + var topBandHeight: CGFloat + var notchWidth: CGFloat + var inset: CGFloat = 0 + + var animatableData: AnimatablePair { + get { .init(cornerRadius, notchWidth) } + set { cornerRadius = newValue.first; notchWidth = newValue.second } + } + + func inset(by amount: CGFloat) -> NotchShape { + var copy = self + copy.inset += amount + return copy + } + + func path(in rect: CGRect) -> Path { + let rect = rect.insetBy(dx: inset, dy: inset) + // Ist das Panel nicht breiter als die Notch, ist der Steg die ganze + // Form — dann bleibt es die schlichte abgerundete Fläche. + let band = min(max(notchWidth - 2 * inset, 0), rect.width) + let radius = min(cornerRadius, rect.width / 2, rect.height / 2) + + var path = Path() + guard topBandHeight > 0, band < rect.width - 0.5 else { + path.addRoundedRect(in: rect, + cornerSize: CGSize(width: radius, height: radius), + style: .continuous) + return path + } + + let bandRect = CGRect(x: rect.midX - band / 2, y: rect.minY, + width: band, height: topBandHeight + radius) + let body = CGRect(x: rect.minX, y: rect.minY + topBandHeight, + width: rect.width, height: rect.height - topBandHeight) + + // Der Steg bekommt keine Rundung nach oben: dort sitzt die Notch, und + // eine Rundung an der Bildschirmkante wäre ohnehin nicht zu sehen. + path.addRect(bandRect) + path.addRoundedRect(in: body, + cornerSize: CGSize(width: radius, height: radius), + style: .continuous) + return path + } +} + /// Der Rahmen, der aus der Notch herauswächst. /// /// Die Fläche wird **nicht** eingeblendet. Sie beginnt exakt in der Notch-Silhouette @@ -66,7 +129,9 @@ public struct NotchContainer: View { } public var body: some View { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + let shape = NotchShape(cornerRadius: cornerRadius, + topBandHeight: presentation.contentTopInset, + notchWidth: presentation.notchWidth) ZStack(alignment: .top) { // Das Weichzeichner-Material behält **feste** Größe; animiert wird @@ -94,7 +159,7 @@ public struct NotchContainer: View { .init(color: .clear, location: 0.4)], startPoint: .top, endPoint: .bottom)) } - .overlay { shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1) } + .overlay { shape.stroke(Onyx.Color.hairline, lineWidth: 1) } .frame(width: size.width, height: size.height) // Konstante Deckkraft: einen Schatten aufzublenden kostet in // jedem Einzelbild eine neue Weichzeichnung. Er schrumpft @@ -159,6 +224,17 @@ public final class NotchHostingView: NSHostingView { width: size.width, height: size.height) guard visible.contains(point) else { return nil } + + // Links und rechts neben der Notch gehört der oberste Streifen der + // Menüleiste. Dort ist das Panel durchsichtig — und muss auch + // durchlässig sein, sonst schluckt es Klicks auf fremde + // Statuselemente, ohne dass man sieht, warum. + if presentation.isExpanded, point.y < presentation.contentTopInset { + let band = CGRect(x: (bounds.width - presentation.notchWidth) / 2, y: 0, + width: presentation.notchWidth, + height: presentation.contentTopInset) + guard band.contains(point) else { return nil } + } return super.hitTest(point) } } diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift index 411e5a2..51fdbaf 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift @@ -75,15 +75,25 @@ public final class NotchScreenController { // groß wie das ausgefahrene Panel, damit die Animation Platz hat — // würde man `panel.frame` nehmen, hielte ein Zeiger in der noch leeren // Fläche das Panel offen. - let inside = geometry.triggerRect.contains(point) - || (presentation.isExpanded && panel.frame.contains(point)) + let inside = geometry.triggerRect.contains(point) || containsVisiblePoint(point) send(inside ? .pointerEntered : .pointerExited) } public func click() { send(.click) } public func clickedOutside() { send(.clickedOutside) } - /// Für den Koordinator: wo das Panel gerade steht. - public var panelFrame: CGRect { panel.frame } + + /// Ob ein Punkt auf der **sichtbaren** Fläche des Panels liegt. + /// + /// Nicht `panel.frame`: das Fenster reicht bis an die Bildschirmoberkante + /// und ist links und rechts der Notch dort durchsichtig — dieser Streifen + /// gehört der Menüleiste. Wer den Fensterrahmen befragt, hält einen Klick + /// auf ein fremdes Statuselement für einen Klick ins Panel. + public func containsVisiblePoint(_ point: CGPoint) -> Bool { + guard presentation.isExpanded, panel.frame.contains(point) else { return false } + let menuBarBand = panel.frame.maxY - geometry.rect.height + guard point.y > menuBarBand else { return true } + return abs(point.x - panel.frame.midX) <= geometry.rect.width / 2 + } public func escape() { send(.escape) } public func setSuppressed(_ suppressed: Bool) { guard suppressed != machine.isSuppressed else { return }