feat: Ask AI 刘海弹窗输出模式
- 新增 AssistOutputMode 配置(inject/notch) - Assist 设置界面添加输出方式弹出菜单 - 新建 NotchDisplayManager:DynamicNotchKit 弹窗,支持滚动、复制、15s 自动消失 - DynamicNotchKit 固化为本地依赖,修复假刘海高度问题(0→内容贴近顶部)
This commit is contained in:
parent
d43d1dbb38
commit
c708ff32ac
27
Dependencies/DynamicNotchKit/Package.swift
vendored
Normal file
27
Dependencies/DynamicNotchKit/Package.swift
vendored
Normal file
@ -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"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
29
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/BlurModifier.swift
vendored
Normal file
29
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/BlurModifier.swift
vendored
Normal file
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
205
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift
vendored
Normal file
205
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift
vendored
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
//
|
||||||
|
// DynamicNotch.swift
|
||||||
|
// DynamicNotchKit
|
||||||
|
//
|
||||||
|
// Created by Kai Azim on 2023-08-24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - DynamicNotch
|
||||||
|
|
||||||
|
public class DynamicNotch<Content>: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
169
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchInfo.swift
vendored
Normal file
169
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchInfo.swift
vendored
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
//
|
||||||
|
// DynamicNotchInfoWindow.swift
|
||||||
|
// DynamicNotchKit
|
||||||
|
//
|
||||||
|
// Created by Kai Azim on 2023-08-25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
internal final class DynamicNotchInfoPublisher<IconView>: 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<IconView> where IconView: View {
|
||||||
|
private var internalDynamicNotch: DynamicNotch<InfoView>
|
||||||
|
private let publisher: DynamicNotchInfoPublisher<IconView>
|
||||||
|
|
||||||
|
public init(contentID: UUID = .init(), icon: Image! = nil, title: String, description: String? = nil, iconColor: Color = .white, textColor: Color = .white, style: DynamicNotch<InfoView>.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<InfoView>.Style = .auto, iconView: (() -> IconView)? = nil) {
|
||||||
|
let publisher = DynamicNotchInfoPublisher<IconView>(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<IconView>
|
||||||
|
|
||||||
|
init(publisher: DynamicNotchInfoPublisher<IconView>) {
|
||||||
|
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<IconView>
|
||||||
|
|
||||||
|
init(publisher: DynamicNotchInfoPublisher<IconView>) {
|
||||||
|
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<IconView>
|
||||||
|
|
||||||
|
init(publisher: DynamicNotchInfoPublisher<IconView>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchPanel.swift
vendored
Normal file
22
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchPanel.swift
vendored
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// DynamicNotchPanel.swift
|
||||||
|
// DynamicNotchKit
|
||||||
|
//
|
||||||
|
// Created by <Huy D.> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
168
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchProgress.swift
vendored
Normal file
168
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotchProgress.swift
vendored
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
//
|
||||||
|
// DynamicNotchProgress.swift
|
||||||
|
// DynamicNotchKit
|
||||||
|
//
|
||||||
|
// Created by Kai Azim on 2024-08-30.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public class DynamicNotchProgress {
|
||||||
|
private var internalDynamicNotch: DynamicNotch<InfoView>
|
||||||
|
|
||||||
|
public init(progress: Binding<CGFloat>, title: String, description: String? = nil, progressText: Bool = false, progressBarColor: Color = .white, textColor: Color = .white, style: DynamicNotch<InfoView>.Style = .auto) {
|
||||||
|
internalDynamicNotch = DynamicNotch(style: style) {
|
||||||
|
InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, numberOverlay: progressText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setContent(progress: Binding<CGFloat>, 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<CGFloat>, title: String, description: String? = nil, iconOverlay: Image! = nil, progressBarColor: Color = .white, textColor: Color = .white, style: DynamicNotch<InfoView>.Style = .auto) {
|
||||||
|
internalDynamicNotch = DynamicNotch(style: style) {
|
||||||
|
InfoView(progress: progress, progressBarColor: progressBarColor, title: title, description: description, textColor: textColor, iconOverlay: iconOverlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setContent(progress: Binding<CGFloat>, 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<CGFloat>, 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<CGFloat>, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NSScreen+Extensions.swift
vendored
Normal file
45
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NSScreen+Extensions.swift
vendored
Normal file
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchShape.swift
vendored
Normal file
51
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchShape.swift
vendored
Normal file
@ -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)
|
||||||
|
}
|
||||||
67
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift
vendored
Normal file
67
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift
vendored
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// NotchView.swift
|
||||||
|
// DynamicNotchKit
|
||||||
|
//
|
||||||
|
// Created by Kai Azim on 2023-08-24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NotchView<Content>: View where Content: View {
|
||||||
|
@ObservedObject var dynamicNotch: DynamicNotch<Content>
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchlessView.swift
vendored
Normal file
56
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchlessView.swift
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// NotchlessView.swift
|
||||||
|
// DynamicNotchKit
|
||||||
|
//
|
||||||
|
// Created by Kai Azim on 2024-04-06.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NotchlessView<Content>: View where Content: View {
|
||||||
|
@ObservedObject var dynamicNotch: DynamicNotch<Content>
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/VisualEffectView.swift
vendored
Normal file
27
Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/VisualEffectView.swift
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,9 +4,13 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "VoiceInput",
|
name: "VoiceInput",
|
||||||
platforms: [.macOS(.v14)],
|
platforms: [.macOS(.v14)],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "Dependencies/DynamicNotchKit")
|
||||||
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "VoiceInput",
|
name: "VoiceInput",
|
||||||
|
dependencies: ["DynamicNotchKit"],
|
||||||
path: "Sources",
|
path: "Sources",
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
.linkedFramework("Speech"),
|
.linkedFramework("Speech"),
|
||||||
|
|||||||
@ -316,8 +316,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let result = try await llmRefiner.chat(text: text, config: config)
|
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 {
|
} catch {
|
||||||
print("[App] LLM chat error: \(error)")
|
print("[App] LLM chat error: \(error)")
|
||||||
|
|||||||
96
Sources/Core/NotchDisplayManager.swift
Normal file
96
Sources/Core/NotchDisplayManager.swift
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import Cocoa
|
||||||
|
import SwiftUI
|
||||||
|
import DynamicNotchKit
|
||||||
|
|
||||||
|
/// 管理 AI 回复的刘海弹窗展示
|
||||||
|
final class NotchDisplayManager {
|
||||||
|
|
||||||
|
static let shared = NotchDisplayManager()
|
||||||
|
|
||||||
|
private var currentNotch: DynamicNotch<NotchContentView>?
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,10 @@ final class SettingsWindow: NSWindow {
|
|||||||
private let copyFromOtherButton: NSButton
|
private let copyFromOtherButton: NSButton
|
||||||
private let statusLabel: NSTextField
|
private let statusLabel: NSTextField
|
||||||
|
|
||||||
|
// Assist 模式专属:输出方式
|
||||||
|
private let outputModeLabel: NSTextField
|
||||||
|
private let outputModePopup: NSPopUpButton
|
||||||
|
|
||||||
init(mode: LLMMode) {
|
init(mode: LLMMode) {
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
|
|
||||||
@ -22,8 +26,10 @@ final class SettingsWindow: NSWindow {
|
|||||||
saveButton = NSButton(title: "Save", target: nil, action: nil)
|
saveButton = NSButton(title: "Save", target: nil, action: nil)
|
||||||
copyFromOtherButton = NSButton(title: "", target: nil, action: nil)
|
copyFromOtherButton = NSButton(title: "", target: nil, action: nil)
|
||||||
statusLabel = NSTextField(labelWithString: "")
|
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(
|
super.init(
|
||||||
contentRect: windowRect,
|
contentRect: windowRect,
|
||||||
styleMask: [.titled, .closable, .miniaturizable],
|
styleMask: [.titled, .closable, .miniaturizable],
|
||||||
@ -148,12 +154,42 @@ final class SettingsWindow: NSWindow {
|
|||||||
content.addSubview(copyFromOtherButton)
|
content.addSubview(copyFromOtherButton)
|
||||||
content.addSubview(statusLabel)
|
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([
|
NSLayoutConstraint.activate([
|
||||||
grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
||||||
grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
|
grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
|
||||||
grid.trailingAnchor.constraint(equalTo: content.trailingAnchor, 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.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
||||||
buttonStack.widthAnchor.constraint(equalToConstant: 220),
|
buttonStack.widthAnchor.constraint(equalToConstant: 220),
|
||||||
|
|
||||||
@ -171,6 +207,9 @@ final class SettingsWindow: NSWindow {
|
|||||||
baseURLField.stringValue = config.baseURL
|
baseURLField.stringValue = config.baseURL
|
||||||
apiKeyField.stringValue = config.apiKey
|
apiKeyField.stringValue = config.apiKey
|
||||||
modelField.stringValue = config.model
|
modelField.stringValue = config.model
|
||||||
|
if mode == .assist {
|
||||||
|
outputModePopup.selectItem(at: Config.shared.assistOutputMode == .notch ? 1 : 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func testConnection() {
|
@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() {
|
@objc private func saveConfig() {
|
||||||
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
let key = apiKeyField.stringValue
|
let key = apiKeyField.stringValue
|
||||||
|
|||||||
@ -28,6 +28,18 @@ enum TriggerMode: String {
|
|||||||
case assist = "assist" // AI 助手
|
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 {
|
struct LLMConfig {
|
||||||
var enabled: Bool
|
var enabled: Bool
|
||||||
var baseURL: String
|
var baseURL: String
|
||||||
@ -61,6 +73,9 @@ final class Config {
|
|||||||
static let assistLLMBaseURL = "assist_llm_base_url"
|
static let assistLLMBaseURL = "assist_llm_base_url"
|
||||||
static let assistLLMAPIKey = "assist_llm_api_key"
|
static let assistLLMAPIKey = "assist_llm_api_key"
|
||||||
static let assistLLMModel = "assist_llm_model"
|
static let assistLLMModel = "assist_llm_model"
|
||||||
|
|
||||||
|
// 助手模式输出方式
|
||||||
|
static let assistOutputMode = "assist_output_mode"
|
||||||
}
|
}
|
||||||
|
|
||||||
var language: RecognitionLanguage {
|
var language: RecognitionLanguage {
|
||||||
@ -168,6 +183,21 @@ final class Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func getLLMConfig(for mode: LLMMode) -> LLMConfig {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .input: return inputLLM
|
case .input: return inputLLM
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user