diff --git a/Dependencies/DynamicNotchKit/Package.swift b/Dependencies/DynamicNotchKit/Package.swift new file mode 100644 index 0000000..ce09e2a --- /dev/null +++ b/Dependencies/DynamicNotchKit/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "DynamicNotchKit", + platforms: [ + .macOS(.v12) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "DynamicNotchKit", + targets: ["DynamicNotchKit"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "DynamicNotchKit", + path: "Sources"), + .testTarget( + name: "DynamicNotchKitTests", + dependencies: ["DynamicNotchKit"]), + ] +) diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/BlurModifier.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/BlurModifier.swift new file mode 100644 index 0000000..d9c649c --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/BlurModifier.swift @@ -0,0 +1,29 @@ +// +// BlurModifier.swift +// DynamicNotchKit +// +// Created by Kai Azim on 2024-08-30. +// + +import SwiftUI + +// This transition is used to animate the blur effect +private struct BlurModifier: ViewModifier { + public let isIdentity: Bool + public var intensity: CGFloat + + public func body(content: Content) -> some View { + content + .blur(radius: isIdentity ? intensity : 0) + .opacity(isIdentity ? 0 : 1) + } +} + +extension AnyTransition { + static var blur: AnyTransition { + .modifier( + active: BlurModifier(isIdentity: true, intensity: 5), + identity: BlurModifier(isIdentity: false, intensity: 5) + ) + } +} diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift new file mode 100644 index 0000000..c17774a --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift @@ -0,0 +1,205 @@ +// +// DynamicNotch.swift +// DynamicNotchKit +// +// Created by Kai Azim on 2023-08-24. +// + +import Combine +import SwiftUI + +// MARK: - DynamicNotch + +public class DynamicNotch: ObservableObject where Content: View { + + public var windowController: NSWindowController? // Make public in case user wants to modify the NSPanel + + // Content Properties + @Published var content: () -> Content + @Published var contentID: UUID + @Published var isVisible: Bool = false // Used to animate the fading in/out of the user's view + + // Notch Size + @Published var notchWidth: CGFloat = 0 + @Published var notchHeight: CGFloat = 0 + + // Notch Closing Properties + @Published var isMouseInside: Bool = false // If the mouse is inside, the notch will not auto-hide + private var timer: Timer? + var workItem: DispatchWorkItem? + private var subscription: AnyCancellable? + + // Notch Style + private var notchStyle: Style = .notch + public enum Style { + case notch + case floating + case auto + } + + private var maxAnimationDuration: Double = 0.8 // This is a timer to de-init the window after closing + var animation: Animation { + if #available(macOS 14.0, *), notchStyle == .notch { + Animation.spring(.bouncy(duration: 0.4)) + } else { + Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.7) + } + } + + /// Makes a new DynamicNotch with custom content and style. + /// - Parameters: + /// - content: A SwiftUI View + /// - style: The popover's style. If unspecified, the style will be automatically set according to the screen. + public init(contentID: UUID = .init(), style: DynamicNotch.Style = .auto, @ViewBuilder content: @escaping () -> Content) { + self.contentID = contentID + self.content = content + self.notchStyle = style + self.subscription = NotificationCenter.default + .publisher(for: NSApplication.didChangeScreenParametersNotification) + .sink { [weak self] _ in + guard let self, let screen = NSScreen.screens.first else { return } + initializeWindow(screen: screen) + } + } +} + +// MARK: - Public + +public extension DynamicNotch { + + /// Set this DynamicNotch's content. + /// - Parameter content: A SwiftUI View + func setContent(contentID: UUID = .init(), content: @escaping () -> Content) { + self.content = content + self.contentID = .init() + } + + /// Show the DynamicNotch. + /// - Parameters: + /// - screen: Screen to show on. Default is the primary screen. + /// - time: Time to show in seconds. If 0, the DynamicNotch will stay visible until `hide()` is called. + func show(on screen: NSScreen = NSScreen.screens[0], for time: Double = 0) { + func scheduleHide(_ time: Double) { + let workItem = DispatchWorkItem { self.hide() } + self.workItem?.cancel() + self.workItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + time, execute: workItem) + } + + guard !isVisible else { + if time > 0 { + scheduleHide(time) + } + return + } + timer?.invalidate() + + initializeWindow(screen: screen) + DispatchQueue.main.async { + withAnimation(self.animation) { + self.isVisible = true + } + } + + if time != 0 { + scheduleHide(time) + } + } + + /// Hide the DynamicNotch. + func hide(ignoreMouse: Bool = false) { + guard isVisible else { return } + + if !ignoreMouse, isMouseInside { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.hide() + } + return + } + + withAnimation(animation) { + self.isVisible = false + } + + timer = Timer.scheduledTimer(withTimeInterval: maxAnimationDuration, repeats: false) { _ in + self.deinitializeWindow() + } + } + + /// Toggle the DynamicNotch's visibility. + func toggle() { + if isVisible { + hide() + } else { + show() + } + } + + /// Check if the cursor is inside the screen's notch area. + /// - Returns: If the cursor is inside the notch area. + static func checkIfMouseIsInNotch() -> Bool { + guard let screen = NSScreen.screenWithMouse else { + return false + } + + let notchWidth: CGFloat = 300 + let notchHeight: CGFloat = screen.frame.maxY - screen.visibleFrame.maxY // menubar height + + let notchFrame = screen.notchFrame ?? NSRect( + x: screen.frame.midX - (notchWidth / 2), + y: screen.frame.maxY - notchHeight, + width: notchWidth, + height: notchHeight + ) + + return notchFrame.contains(NSEvent.mouseLocation) + } +} + +// MARK: - Private + +extension DynamicNotch { + + func refreshNotchSize(_ screen: NSScreen) { + if let notchSize = screen.notchSize { + notchWidth = notchSize.width + notchHeight = notchSize.height + } else { + notchWidth = 300 + notchHeight = 0 + } + } + + func initializeWindow(screen: NSScreen) { + // so that we don't have a duplicate window + deinitializeWindow() + + refreshNotchSize(screen) + + let view: NSView = { + switch notchStyle { + case .notch: NSHostingView(rootView: NotchView(dynamicNotch: self).foregroundStyle(.white)) + case .floating: NSHostingView(rootView: NotchlessView(dynamicNotch: self)) + case .auto: screen.hasNotch ? NSHostingView(rootView: NotchView(dynamicNotch: self).foregroundStyle(.white)) : NSHostingView(rootView: NotchlessView(dynamicNotch: self)) + } + }() + + let panel = DynamicNotchPanel( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + panel.contentView = view + panel.orderFrontRegardless() + panel.setFrame(screen.frame, display: false) + + windowController = .init(window: panel) + } + + func deinitializeWindow() { + guard let windowController else { return } + windowController.close() + self.windowController = nil + } +} diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchInfo.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchInfo.swift new file mode 100644 index 0000000..20924f6 --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchInfo.swift @@ -0,0 +1,169 @@ +// +// DynamicNotchInfoWindow.swift +// DynamicNotchKit +// +// Created by Kai Azim on 2023-08-25. +// + +import SwiftUI + +internal final class DynamicNotchInfoPublisher: ObservableObject where IconView: View { + @Published var iconView: IconView? + @Published var iconColor: Color + @Published var title: String + @Published var description: String? + @Published var textColor: Color + + init(icon: Image?, iconColor: Color, title: String, description: String? = nil, textColor: Color) where IconView == Image { + self.iconView = icon + self.iconColor = iconColor + self.title = title + self.description = description + self.textColor = textColor + } + + init(title: String, description: String? = nil, textColor: Color, iconView: (() -> IconView)?) { + self.title = title + self.description = description + self.textColor = textColor + self.iconColor = .clear + self.iconView = iconView?() + } + + @MainActor + func publish(icon: Image?, iconColor: Color, title: String, description: String?, textColor: Color) where IconView == Image { + self.iconView = icon + self.iconColor = iconColor + self.title = title + self.description = description + self.textColor = textColor + } + + @MainActor + func publish(icon: IconView?, title: String, description: String?) { + self.title = title + self.description = description + self.iconView = iconView + } +} + +public class DynamicNotchInfo where IconView: View { + private var internalDynamicNotch: DynamicNotch + private let publisher: DynamicNotchInfoPublisher + + public init(contentID: UUID = .init(), icon: Image! = nil, title: String, description: String? = nil, iconColor: Color = .white, textColor: Color = .white, style: DynamicNotch.Style = .auto) where IconView == Image { + let publisher = DynamicNotchInfoPublisher(icon: icon, iconColor: iconColor, title: title, description: description, textColor: textColor) + self.publisher = publisher + internalDynamicNotch = DynamicNotch(contentID: contentID, style: style) { + InfoView(publisher: publisher) + } + } + + public init(contentID: UUID = .init(), title: String, description: String? = nil, textColor: Color = .white, style: DynamicNotch.Style = .auto, iconView: (() -> IconView)? = nil) { + let publisher = DynamicNotchInfoPublisher(title: title, description: description, textColor: textColor, iconView: iconView) + self.publisher = publisher + internalDynamicNotch = DynamicNotch(contentID: contentID, style: style) { + InfoView(publisher: publisher) + } + } + + @MainActor + public func setContent(contentID: UUID = .init(), icon: Image? = nil, title: String, description: String? = nil, iconColor: Color = .white, textColor: Color = .white) where IconView == Image { + withAnimation { + publisher.publish(icon: icon, iconColor: iconColor, title: title, description: description, textColor: textColor) + } + } + + @MainActor + public func setContent(contentID: UUID = .init(), title: String, description: String? = nil, iconView: IconView? = nil) { + withAnimation { + publisher.publish(icon: iconView, title: title, description: description) + } + } + + public func show(on screen: NSScreen = NSScreen.screens[0], for time: Double = 0) { + internalDynamicNotch.show(on: screen, for: time) + } + + public func hide() { + internalDynamicNotch.hide() + } + + public func toggle() { + internalDynamicNotch.toggle() + } +} + +public extension DynamicNotchInfo { + struct InfoView: View { + private var publisher: DynamicNotchInfoPublisher + + init(publisher: DynamicNotchInfoPublisher) { + self.publisher = publisher + } + + public var body: some View { + HStack(spacing: 10) { + InfoImageView(publisher: publisher) + InfoTextView(publisher: publisher) + Spacer(minLength: 0) + } + .frame(height: 40) + } + } + + struct InfoImageView: View { + @ObservedObject private var publisher: DynamicNotchInfoPublisher + + init(publisher: DynamicNotchInfoPublisher) { + self.publisher = publisher + } + + public var body: some View { + if let image = publisher.iconView as? Image { + image + .resizable() + .foregroundStyle(publisher.iconColor) + .padding(3) + .scaledToFit() + } else if let iconView = publisher.iconView { + iconView + } else { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .padding(-5) + .scaledToFit() + } + } + } + + struct InfoTextView: View { + @ObservedObject private var publisher: DynamicNotchInfoPublisher + + init(publisher: DynamicNotchInfoPublisher) { + self.publisher = publisher + } + + public var body: some View { + VStack(alignment: .leading, spacing: publisher.description != nil ? nil : 0) { + Text(publisher.title) + .font(.headline) + .foregroundStyle(publisher.textColor) + Text(publisher.description ?? "") + .font(.caption2) + .foregroundStyle(publisher.textColor.opacity(0.8)) + .opacity(publisher.description != nil ? 1 : 0) + .frame(maxHeight: publisher.description != nil ? nil : 0) + } + } + } +} + +struct DynamicNotchInfo_Previews: PreviewProvider { + static let publisher = DynamicNotchInfoPublisher(icon: nil, iconColor: .blue, title: "testing", textColor: .black) + static var previews: some View { + VStack { + DynamicNotchInfo.InfoView(publisher: publisher) + } + } +} diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchPanel.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchPanel.swift new file mode 100644 index 0000000..6ab2ed6 --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchPanel.swift @@ -0,0 +1,22 @@ +// +// DynamicNotchPanel.swift +// DynamicNotchKit +// +// Created by on 2024-11-01. +// + +import AppKit + +class DynamicNotchPanel: NSPanel { + override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { + super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) + self.hasShadow = false + self.backgroundColor = .clear + self.level = .screenSaver + self.collectionBehavior = [.canJoinAllSpaces, .stationary] + } + + override var canBecomeKey: Bool { + true + } +} diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchProgress.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchProgress.swift new file mode 100644 index 0000000..1e1314a --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchProgress.swift @@ -0,0 +1,168 @@ +// +// DynamicNotchProgress.swift +// DynamicNotchKit +// +// Created by Kai Azim on 2024-08-30. +// + +import SwiftUI + +public class DynamicNotchProgress { + private var internalDynamicNotch: DynamicNotch + + public init(progress: Binding, title: String, description: String? = nil, progressText: Bool = false, progressBarColor: Color = .white, textColor: Color = .white, style: DynamicNotch.Style = .auto) { + internalDynamicNotch = DynamicNotch(style: style) { + InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, numberOverlay: progressText) + } + } + + public func setContent(progress: Binding, title: String, description: String? = nil, progressText: Bool = false, progressBarColor: Color = .white, textColor: Color = .white) { + internalDynamicNotch.setContent { + InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, numberOverlay: progressText) + } + } + + public init(progress: Binding, title: String, description: String? = nil, iconOverlay: Image! = nil, progressBarColor: Color = .white, textColor: Color = .white, style: DynamicNotch.Style = .auto) { + internalDynamicNotch = DynamicNotch(style: style) { + InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, iconOverlay: iconOverlay) + } + } + + public func setContent(progress: Binding, title: String, description: String? = nil, iconOverlay: Image! = nil, progressBarColor: Color = .white, textColor: Color = .white) { + internalDynamicNotch.setContent { + InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, iconOverlay: iconOverlay) + } + } + + public func show(on screen: NSScreen = NSScreen.screens[0], for time: Double = 0) { + internalDynamicNotch.show(on: screen, for: time) + } + + public func hide() { + internalDynamicNotch.hide() + } + + public func toggle() { + internalDynamicNotch.toggle() + } +} + +public extension DynamicNotchProgress { + struct InfoView: View { + @Binding var progress: CGFloat + let progressBarColor: Color + + let title: String + let description: String? + let textColor: Color + + let numberOverlay: Bool? + let iconOverlay: Image? + + init(progress: Binding, progressBarColor: Color, title: String, description: String? = nil, textColor: Color, numberOverlay: Bool? = nil, iconOverlay: Image? = nil) { + _progress = progress + self.progressBarColor = progressBarColor + self.title = title + self.description = description + self.textColor = textColor + self.numberOverlay = numberOverlay + self.iconOverlay = iconOverlay + } + + public var body: some View { + HStack(spacing: 10) { + ProgressRing(to: $progress, color: progressBarColor) + .overlay { + if numberOverlay == true { + if #available(macOS 13.0, *) { + Text("\(Int(progress * 100))%") + .font(.caption) + .foregroundStyle(textColor) + .contentTransition(.numericText()) // .numericText() is only available on macOS 13+ + .animation(.smooth, value: progress) + } else { + Text("\(Int(progress * 100))%") + .font(.caption) + .foregroundStyle(textColor) + } + } + + if iconOverlay != nil { + iconOverlay + .foregroundStyle(progressBarColor) + } + } + textView() + Spacer(minLength: 0) + } + .frame(height: 40) + } + + func textView() -> some View { + VStack(alignment: .leading) { + Text(title) + .font(.headline) + .foregroundStyle(textColor) + + if let description { + Text(description) + .font(.caption2) + .foregroundStyle(textColor.opacity(0.8)) + } + } + } + } + + struct ProgressRing: View { + @Binding var target: CGFloat + let color: Color + let thickness: CGFloat + + @State private var isLoaded = false + + public init(to target: Binding, color: Color = .white, thickness: CGFloat = 5) { + self._target = target + self.color = color + self.thickness = thickness + } + + public var body: some View { + Circle() + .stroke(style: StrokeStyle(lineWidth: thickness)) + .foregroundStyle(.tertiary) + .overlay { + // Foreground ring + if #available(macOS 13.0, *) { + Circle() + .trim(from: 0, to: isLoaded ? target : 0) + .stroke( + color.gradient, // Gradient is only available on macOS 13+ + style: StrokeStyle( + lineWidth: thickness, + lineCap: .round + ) + ) + .opacity(isLoaded ? 1 : 0) + } else { + Circle() + .trim(from: 0, to: isLoaded ? target : 0) + .stroke( + color, + style: StrokeStyle( + lineWidth: thickness, + lineCap: .round + ) + ) + .opacity(isLoaded ? 1 : 0) + } + } + .rotationEffect(.degrees(-90)) + .padding(thickness / 2) + .task { + withAnimation(Animation.timingCurve(0.22, 1, 0.36, 1, duration: 1)) { + isLoaded = true + } + } + } + } +} diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NSScreen+Extensions.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NSScreen+Extensions.swift new file mode 100644 index 0000000..5531b90 --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NSScreen+Extensions.swift @@ -0,0 +1,45 @@ +// +// NSScreen+Extensions.swift +// DynamicNotchKit +// +// Created by Kai Azim on 2024-04-06. +// + +import SwiftUI + +extension NSScreen { + static var screenWithMouse: NSScreen? { + let mouseLocation = NSEvent.mouseLocation + let screens = NSScreen.screens + let screenWithMouse = (screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }) + + return screenWithMouse + } + + var hasNotch: Bool { + auxiliaryTopLeftArea?.width != nil && auxiliaryTopRightArea?.width != nil + } + + var notchSize: NSSize? { + guard + let topLeftNotchpadding: CGFloat = auxiliaryTopLeftArea?.width, + let topRightNotchpadding: CGFloat = auxiliaryTopRightArea?.width + else { + return nil + } + + let notchHeight = safeAreaInsets.top + let notchWidth = frame.width - topLeftNotchpadding - topRightNotchpadding + 10 // 10 is for the top rounded part of the notch, created by DynamicNotchKit + return .init(width: notchWidth, height: notchHeight) + } + + var notchFrame: NSRect? { + guard let notchSize else { return nil } + return .init( + x: frame.midX - (notchSize.width / 2), + y: frame.maxY - notchSize.height, + width: notchSize.width, + height: notchSize.height + ) + } +} diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchShape.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchShape.swift new file mode 100644 index 0000000..81a5674 --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchShape.swift @@ -0,0 +1,51 @@ +// +// NotchShape.swift +// DynamicNotchKit +// +// Created by Kai Azim on 2023-08-24. +// + +import SwiftUI + +struct NotchShape: Shape { + var topCornerRadius: CGFloat { + bottomCornerRadius - 5 + } + + var bottomCornerRadius: CGFloat + + init(cornerRadius: CGFloat? = nil) { + if cornerRadius == nil { + self.bottomCornerRadius = 11 + } else { + self.bottomCornerRadius = cornerRadius! + } + } + + var animatableData: CGFloat { + get { bottomCornerRadius } + set { bottomCornerRadius = newValue } + } + + func path(in rect: CGRect) -> Path { + var path = Path() + + path.addArc(center: CGPoint(x: rect.minX, y: topCornerRadius), radius: topCornerRadius, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false) + path.addLine(to: CGPoint(x: rect.minX + topCornerRadius, y: rect.maxY - bottomCornerRadius)) + path.addArc(center: CGPoint(x: rect.minX + topCornerRadius + bottomCornerRadius, y: rect.maxY - bottomCornerRadius), radius: bottomCornerRadius, startAngle: .degrees(180), endAngle: .degrees(90), clockwise: true) + path.addLine(to: CGPoint(x: rect.maxX - topCornerRadius - bottomCornerRadius, y: rect.maxY)) + path.addArc(center: CGPoint(x: rect.maxX - topCornerRadius - bottomCornerRadius, y: rect.maxY - bottomCornerRadius), radius: bottomCornerRadius, startAngle: .degrees(90), endAngle: .degrees(0), clockwise: true) + path.addLine(to: CGPoint(x: rect.maxX - topCornerRadius, y: rect.minY + bottomCornerRadius)) + + path.addArc(center: CGPoint(x: rect.maxX, y: topCornerRadius), radius: topCornerRadius, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) + + return path + } +} + +#Preview { + NotchShape() + .frame(width: 200, height: 32) + .padding(10) +} diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift new file mode 100644 index 0000000..9ff411d --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift @@ -0,0 +1,67 @@ +// +// NotchView.swift +// DynamicNotchKit +// +// Created by Kai Azim on 2023-08-24. +// + +import SwiftUI + +struct NotchView: View where Content: View { + @ObservedObject var dynamicNotch: DynamicNotch + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Spacer() + + VStack(spacing: 0) { + Spacer() + .frame(width: dynamicNotch.notchWidth + 20, height: dynamicNotch.notchHeight) + // We add an extra 20 here because the corner radius of the top increases when shown. + // (the remaining 10 has already been accounted for in refreshNotchSize) + + dynamicNotch.content() + .id(dynamicNotch.contentID) + .safeAreaInset(edge: .bottom, spacing: 0) { Color.clear.frame(height: 15) } + .safeAreaInset(edge: .leading, spacing: 0) { Color.clear.frame(width: 15) } + .safeAreaInset(edge: .trailing, spacing: 0) { Color.clear.frame(width: 15) } + + .blur(radius: dynamicNotch.isVisible ? 0 : 10) + .scaleEffect(dynamicNotch.isVisible ? 1 : 0.8) + .offset(y: dynamicNotch.isVisible ? 0 : 5) + .padding(.horizontal, 15) // Small corner radius of the TOP of the notch + .transition(.blur.animation(.smooth)) + } + .fixedSize() + .frame(minWidth: dynamicNotch.notchWidth) + .onHover { hovering in + dynamicNotch.isMouseInside = hovering + } + .background { + Rectangle() + .foregroundStyle(.black) + .padding(-50) // The opening/closing animation can overshoot, so this makes sure that it's still black + } + .mask { + GeometryReader { _ in // This helps with positioning everything + HStack { + Spacer(minLength: 0) + NotchShape(cornerRadius: dynamicNotch.isVisible ? 20 : nil) + .frame( + width: dynamicNotch.isVisible ? nil : dynamicNotch.notchWidth, + height: dynamicNotch.isVisible ? nil : dynamicNotch.notchHeight + ) + Spacer(minLength: 0) + } + } + } + .shadow(color: .black.opacity(0.5), radius: dynamicNotch.isVisible ? 10 : 0) + .animation(dynamicNotch.animation, value: dynamicNotch.contentID) + + Spacer() + } + Spacer() + } + } +} diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchlessView.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchlessView.swift new file mode 100644 index 0000000..e38d9ba --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchlessView.swift @@ -0,0 +1,56 @@ +// +// NotchlessView.swift +// DynamicNotchKit +// +// Created by Kai Azim on 2024-04-06. +// + +import SwiftUI + +struct NotchlessView: View where Content: View { + @ObservedObject var dynamicNotch: DynamicNotch + @State var windowHeight: CGFloat = 0 + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + Spacer() + + dynamicNotch.content() + .id(dynamicNotch.contentID) + .safeAreaInset(edge: .top, spacing: 0) { Color.clear.frame(height: 15) } + .safeAreaInset(edge: .bottom, spacing: 0) { Color.clear.frame(height: 15) } + .safeAreaInset(edge: .leading, spacing: 0) { Color.clear.frame(width: 15) } + .safeAreaInset(edge: .trailing, spacing: 0) { Color.clear.frame(width: 15) } + .fixedSize() + + .onHover { hovering in + dynamicNotch.isMouseInside = hovering + } + .background { + VisualEffectView(material: .popover, blendingMode: .behindWindow) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(.quaternary, lineWidth: 1) + } + } + .clipShape(.rect(cornerRadius: 20)) + .shadow(color: .black.opacity(0.5), radius: dynamicNotch.isVisible ? 10 : 0) + .padding(20) + .background { + GeometryReader { geo in + Color.clear + .onAppear { + windowHeight = geo.size.height // This makes sure that the floating window FULLY slides off before disappearing + } + } + } + .offset(y: dynamicNotch.isVisible ? dynamicNotch.notchHeight : -windowHeight) + .transition(.blur.animation(.smooth)) + + Spacer() + } + Spacer() + } + } +} diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/VisualEffectView.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/VisualEffectView.swift new file mode 100644 index 0000000..83600dc --- /dev/null +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/VisualEffectView.swift @@ -0,0 +1,27 @@ +// +// VisualEffectView.swift +// DynamicNotchKit +// +// Created by Kai Azim on 2024-04-06. +// + +import SwiftUI + +struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context _: Context) -> NSVisualEffectView { + let visualEffectView = NSVisualEffectView() + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.isEmphasized = true + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context _: Context) { + visualEffectView.material = material + visualEffectView.blendingMode = blendingMode + } +} diff --git a/Package.swift b/Package.swift index be041b0..68236a8 100644 --- a/Package.swift +++ b/Package.swift @@ -4,9 +4,13 @@ import PackageDescription let package = Package( name: "VoiceInput", platforms: [.macOS(.v14)], + dependencies: [ + .package(path: "Dependencies/DynamicNotchKit") + ], targets: [ .executableTarget( name: "VoiceInput", + dependencies: ["DynamicNotchKit"], path: "Sources", linkerSettings: [ .linkedFramework("Speech"), diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 712df05..696eb6a 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -316,8 +316,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Task { @MainActor in do { let result = try await llmRefiner.chat(text: text, config: config) - recordingPanel.hideAnimated { - TextInjector.inject(result.text) + + if Config.shared.assistOutputMode == .notch { + // 刘海弹窗模式 + recordingPanel.hideAnimated() + NotchDisplayManager.shared.show(text: result.text) + } else { + // 直接输入模式(默认) + recordingPanel.hideAnimated { + TextInjector.inject(result.text) + } } } catch { print("[App] LLM chat error: \(error)") diff --git a/Sources/Core/NotchDisplayManager.swift b/Sources/Core/NotchDisplayManager.swift new file mode 100644 index 0000000..9d68a98 --- /dev/null +++ b/Sources/Core/NotchDisplayManager.swift @@ -0,0 +1,96 @@ +import Cocoa +import SwiftUI +import DynamicNotchKit + +/// 管理 AI 回复的刘海弹窗展示 +final class NotchDisplayManager { + + static let shared = NotchDisplayManager() + + private var currentNotch: DynamicNotch? + private var isActive = false + + private init() {} + + /// 在刘海/浮动区域展示 AI 回复 + func show(text: String) { + currentNotch?.hide() + + let content = NotchContentView( + text: text, + onCopy: { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + ) + + currentNotch = DynamicNotch(style: .notch) { + content + } + isActive = true + + // 显示 15 秒;用户鼠标悬停时自动暂停倒计时 + currentNotch?.show(for: 15) + } + + func hide() { + currentNotch?.hide() + currentNotch = nil + isActive = false + } +} + +// MARK: - 弹窗内容视图 + +struct NotchContentView: View { + let text: String + var onCopy: () -> Void + + private let maxHeight: CGFloat = 220 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // 标题栏 + HStack(spacing: 0) { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 10)) + Text("AI 助手回复") + .font(.system(size: 11, weight: .medium)) + } + .foregroundColor(.white.opacity(0.5)) + + Spacer() + + Button(action: onCopy) { + HStack(spacing: 3) { + Image(systemName: "doc.on.doc") + .font(.system(size: 9)) + Text("复制") + .font(.system(size: 10)) + } + .foregroundColor(.white.opacity(0.8)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.white.opacity(0.12)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + // 文本区域 + ScrollView(.vertical, showsIndicators: false) { + Text(text) + .font(.system(size: 13)) + .foregroundColor(.white.opacity(0.85)) + .lineSpacing(4) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: maxHeight) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(width: 320) + } +} diff --git a/Sources/UI/SettingsWindow.swift b/Sources/UI/SettingsWindow.swift index 9f75434..64cb0b6 100644 --- a/Sources/UI/SettingsWindow.swift +++ b/Sources/UI/SettingsWindow.swift @@ -12,6 +12,10 @@ final class SettingsWindow: NSWindow { private let copyFromOtherButton: NSButton private let statusLabel: NSTextField + // Assist 模式专属:输出方式 + private let outputModeLabel: NSTextField + private let outputModePopup: NSPopUpButton + init(mode: LLMMode) { self.mode = mode @@ -22,8 +26,10 @@ final class SettingsWindow: NSWindow { saveButton = NSButton(title: "Save", target: nil, action: nil) copyFromOtherButton = NSButton(title: "", target: nil, action: nil) statusLabel = NSTextField(labelWithString: "") + outputModeLabel = NSTextField(labelWithString: "") + outputModePopup = NSPopUpButton(frame: .zero, pullsDown: false) - let windowRect = NSRect(x: 0, y: 0, width: 420, height: 320) + let windowRect = NSRect(x: 0, y: 0, width: 420, height: mode == .assist ? 360 : 320) super.init( contentRect: windowRect, styleMask: [.titled, .closable, .miniaturizable], @@ -148,12 +154,42 @@ final class SettingsWindow: NSWindow { content.addSubview(copyFromOtherButton) content.addSubview(statusLabel) + // Assist 模式专属:输出方式选择 + let outputLabel: NSTextField? + if mode == .assist { + let label = makeLabel("输出方式:") + outputModePopup.translatesAutoresizingMaskIntoConstraints = false + outputModePopup.addItems(withTitles: AssistOutputMode.allCases.map { $0.displayName }) + outputModePopup.selectItem(at: Config.shared.assistOutputMode == .notch ? 1 : 0) + outputModePopup.target = self + outputModePopup.action = #selector(outputModeChanged) + + content.addSubview(label) + content.addSubview(outputModePopup) + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + label.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 16), + label.widthAnchor.constraint(equalToConstant: 90), + + outputModePopup.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8), + outputModePopup.centerYAnchor.constraint(equalTo: label.centerYAnchor), + outputModePopup.widthAnchor.constraint(equalToConstant: 160) + ]) + outputLabel = label + } else { + outputLabel = nil + } + + // 按钮位置:assist 模式下在输出选择下方,否则在 grid 下方 + let buttonTopItem = (outputLabel ?? grid)! + NSLayoutConstraint.activate([ grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24), grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), grid.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), - buttonStack.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 20), + buttonStack.topAnchor.constraint(equalTo: buttonTopItem.bottomAnchor, constant: 20), buttonStack.centerXAnchor.constraint(equalTo: content.centerXAnchor), buttonStack.widthAnchor.constraint(equalToConstant: 220), @@ -171,6 +207,9 @@ final class SettingsWindow: NSWindow { baseURLField.stringValue = config.baseURL apiKeyField.stringValue = config.apiKey modelField.stringValue = config.model + if mode == .assist { + outputModePopup.selectItem(at: Config.shared.assistOutputMode == .notch ? 1 : 0) + } } @objc private func testConnection() { @@ -220,6 +259,16 @@ final class SettingsWindow: NSWindow { } } + @objc private func outputModeChanged() { + let mode = AssistOutputMode.allCases[outputModePopup.indexOfSelectedItem] + Config.shared.assistOutputMode = mode + statusLabel.stringValue = "✅ 输出方式已更新" + statusLabel.textColor = .systemGreen + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.statusLabel.stringValue = "" + } + } + @objc private func saveConfig() { let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let key = apiKeyField.stringValue diff --git a/Sources/Utils/Config.swift b/Sources/Utils/Config.swift index 7525157..2e746a3 100644 --- a/Sources/Utils/Config.swift +++ b/Sources/Utils/Config.swift @@ -28,6 +28,18 @@ enum TriggerMode: String { case assist = "assist" // AI 助手 } +enum AssistOutputMode: String, CaseIterable { + case inject = "inject" // 直接输入到输入栏 + case notch = "notch" // 刘海弹窗展示 + + var displayName: String { + switch self { + case .inject: return "直接输入" + case .notch: return "刘海弹窗" + } + } +} + struct LLMConfig { var enabled: Bool var baseURL: String @@ -61,6 +73,9 @@ final class Config { static let assistLLMBaseURL = "assist_llm_base_url" static let assistLLMAPIKey = "assist_llm_api_key" static let assistLLMModel = "assist_llm_model" + + // 助手模式输出方式 + static let assistOutputMode = "assist_output_mode" } var language: RecognitionLanguage { @@ -167,6 +182,21 @@ final class Config { defaults.set(newValue.model, forKey: Keys.assistLLMModel) } } + + // MARK: - 助手模式输出方式 + + var assistOutputMode: AssistOutputMode { + get { + guard let raw = defaults.string(forKey: Keys.assistOutputMode), + let mode = AssistOutputMode(rawValue: raw) else { + return .inject + } + return mode + } + set { + defaults.set(newValue.rawValue, forKey: Keys.assistOutputMode) + } + } func getLLMConfig(for mode: LLMMode) -> LLMConfig { switch mode {