蜂鸟Pro v2.0.1 - 基础框架版本 (待完善)
## 当前状态 - 插件界面已完成重命名 (cursorpro → hummingbird) - 双账号池 UI 已实现 (Auto/Pro 卡片) - 后端已切换到 MySQL 数据库 - 添加了 Cursor 官方用量 API 文档 ## 已知问题 (待修复) 1. 激活时检查账号导致无账号时激活失败 2. 未启用无感换号时不应获取账号 3. 账号用量模块不显示 (seamless 未启用时应隐藏) 4. 积分显示为 0 (后端未正确返回) 5. Auto/Pro 双密钥逻辑混乱,状态不同步 6. 账号添加后无自动分析功能 ## 下一版本计划 - 重构数据模型,优化账号状态管理 - 实现 Cursor API 自动分析账号 - 修复激活流程,不依赖账号 - 启用无感时才分配账号 - 完善账号用量实时显示 ## 文件说明 - docs/系统设计文档.md - 完整架构设计 - cursor 官方用量接口.md - Cursor API 文档 - 参考计费/ - Vibeviewer 开源项目参考 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
29
参考计费/Packages/VibeviewerShareUI/Package.swift
Normal file
29
参考计费/Packages/VibeviewerShareUI/Package.swift
Normal file
@@ -0,0 +1,29 @@
|
||||
// swift-tools-version:5.10
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "VibeviewerShareUI",
|
||||
platforms: [
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.library(name: "VibeviewerShareUI", targets: ["VibeviewerShareUI"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../VibeviewerModel")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "VibeviewerShareUI",
|
||||
dependencies: ["VibeviewerModel"],
|
||||
resources: [
|
||||
// 将自定义字体放入 Sources/VibeviewerShareUI/Fonts/ 下
|
||||
// 例如:Satoshi-Regular.otf、Satoshi-Medium.otf、Satoshi-Bold.otf、Satoshi-Italic.otf
|
||||
.process("Fonts"),
|
||||
.process("Images"),
|
||||
.process("Shaders")
|
||||
]
|
||||
),
|
||||
.testTarget(name: "VibeviewerShareUITests", dependencies: ["VibeviewerShareUI"]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct VibeButtonStyle: ButtonStyle {
|
||||
var tintColor: Color
|
||||
|
||||
@GestureState private var isPressing = false
|
||||
private let pressScale: CGFloat = 0.94
|
||||
|
||||
@State private var isHovering: Bool = false
|
||||
|
||||
public init(_ tint: Color) {
|
||||
self.tintColor = tint
|
||||
}
|
||||
|
||||
public func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundStyle(tintColor)
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.overlayBorder(color: tintColor.opacity(isHovering ? 1 : 0.4), lineWidth: 1, cornerRadius: 100)
|
||||
.scaleEffect(configuration.isPressed || isPressing ? pressScale : 1.0)
|
||||
.animation(.snappy(duration: 0.2), value: configuration.isPressed || isPressing)
|
||||
.scaleEffect(isHovering ? 1.05 : 1.0)
|
||||
.onHover { isHovering = $0 }
|
||||
.animation(.easeInOut(duration: 0.2), value: isHovering)
|
||||
}
|
||||
}
|
||||
|
||||
extension ButtonStyle where Self == VibeButtonStyle {
|
||||
public static func vibe(_ tint: Color) -> Self {
|
||||
VibeButtonStyle(tint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
public enum AppFont: String, CaseIterable {
|
||||
case satoshiRegular = "Satoshi-Regular"
|
||||
case satoshiMedium = "Satoshi-Medium"
|
||||
case satoshiBold = "Satoshi-Bold"
|
||||
case satoshiItalic = "Satoshi-Italic"
|
||||
}
|
||||
|
||||
public extension Font {
|
||||
/// Create a Font from AppFont with given size and optional relative weight.
|
||||
static func app(_ font: AppFont, size: CGFloat, weight: Weight? = nil) -> Font {
|
||||
FontsRegistrar.registerAllFonts()
|
||||
let f = Font.custom(font.rawValue, size: size)
|
||||
if let weight {
|
||||
return f.weight(weight)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
/// Convenience semantic fonts
|
||||
static func appTitle(_ size: CGFloat = 20) -> Font { .app(.satoshiBold, size: size) }
|
||||
static func appBody(_ size: CGFloat = 15) -> Font { .app(.satoshiRegular, size: size) }
|
||||
static func appEmphasis(_ size: CGFloat = 15) -> Font { .app(.satoshiMedium, size: size) }
|
||||
static func appCaption(_ size: CGFloat = 12) -> Font { .app(.satoshiRegular, size: size) }
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
|
||||
import CoreText
|
||||
import Foundation
|
||||
|
||||
public enum FontsRegistrar {
|
||||
/// Registers all font files shipped in the module bundle under `Fonts/`.
|
||||
/// Safe to call multiple times; duplicates are ignored by CoreText.
|
||||
public static func registerAllFonts() {
|
||||
let bundle = Bundle.module
|
||||
let subdir = "Fonts"
|
||||
|
||||
let otfURLs = bundle.urls(forResourcesWithExtension: "otf", subdirectory: subdir) ?? []
|
||||
let ttfURLs = bundle.urls(forResourcesWithExtension: "ttf", subdirectory: subdir) ?? []
|
||||
let urls = otfURLs + ttfURLs
|
||||
|
||||
for url in urls {
|
||||
var error: Unmanaged<CFError>?
|
||||
// Use process scope so registration lives for the app lifecycle
|
||||
let ok = CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error)
|
||||
if !ok {
|
||||
// Ignore already-registered errors; other errors can be logged in debug
|
||||
#if DEBUG
|
||||
if let err = error?.takeRetainedValue() {
|
||||
let cfError = err as Error
|
||||
// CFError domain kCTFontManagerErrorDomain code 305 means already registered
|
||||
// We'll just print in debug builds
|
||||
print(
|
||||
"[FontsRegistrar] Font registration error for \(url.lastPathComponent): \(cfError)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import VibeviewerModel
|
||||
|
||||
public extension AIModelBrands {
|
||||
/// 从 SPM 模块资源中加载图片;macOS 直接以文件 URL 读取 PDF/PNG,避免命名查找失败
|
||||
private func moduleImage(_ name: String) -> Image {
|
||||
#if canImport(AppKit)
|
||||
if let url = Bundle.module.url(forResource: name, withExtension: "pdf"),
|
||||
let nsImage = NSImage(contentsOf: url) {
|
||||
return Image(nsImage: nsImage)
|
||||
}
|
||||
if let url = Bundle.module.url(forResource: name, withExtension: "png"),
|
||||
let nsImage = NSImage(contentsOf: url) {
|
||||
return Image(nsImage: nsImage)
|
||||
}
|
||||
// 回退占位(确保界面不会空白)
|
||||
return Image(systemName: "app")
|
||||
#else
|
||||
if let url = Bundle.module.url(forResource: name, withExtension: "pdf"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let uiImage = UIImage(data: data) {
|
||||
return Image(uiImage: uiImage)
|
||||
}
|
||||
if let url = Bundle.module.url(forResource: name, withExtension: "png"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let uiImage = UIImage(data: data) {
|
||||
return Image(uiImage: uiImage)
|
||||
}
|
||||
// 回退占位(确保界面不会空白)
|
||||
return Image(systemName: "app")
|
||||
#endif
|
||||
}
|
||||
|
||||
var logo: Image {
|
||||
switch self {
|
||||
case .gpt:
|
||||
return moduleImage("gpt")
|
||||
case .claude:
|
||||
return moduleImage("claude")
|
||||
case .deepseek:
|
||||
return moduleImage("deepseek")
|
||||
case .gemini:
|
||||
return moduleImage("gemini")
|
||||
case .grok:
|
||||
return moduleImage("grok").renderingMode(.template)
|
||||
case .kimi:
|
||||
return moduleImage("kimi")
|
||||
case .default:
|
||||
return moduleImage("cursor")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// noise.metal
|
||||
// Goby
|
||||
//
|
||||
// Created by Groot on 2025/8/14.
|
||||
//
|
||||
|
||||
#include <metal_stdlib>
|
||||
#include <SwiftUI/SwiftUI_Metal.h>
|
||||
using namespace metal;
|
||||
|
||||
[[ stitchable ]]
|
||||
half4 parameterizedNoise(float2 position, half4 color, float intensity, float frequency, float opacity) {
|
||||
float value = fract(cos(dot(position * frequency, float2(12.9898, 78.233))) * 43758.5453);
|
||||
|
||||
float r = color.r * mix(1.0, value, intensity);
|
||||
float g = color.g * mix(1.0, value, intensity);
|
||||
float b = color.b * mix(1.0, value, intensity);
|
||||
|
||||
return half4(r, g, b, opacity);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#include <metal_stdlib>
|
||||
#include <SwiftUI/SwiftUI.h>
|
||||
using namespace metal;
|
||||
|
||||
[[ stitchable ]]
|
||||
half4 Ripple(
|
||||
float2 position,
|
||||
SwiftUI::Layer layer,
|
||||
float2 origin,
|
||||
float time,
|
||||
float amplitude,
|
||||
float frequency,
|
||||
float decay,
|
||||
float speed
|
||||
) {
|
||||
// The distance of the current pixel position from `origin`.
|
||||
float distance = length(position - origin);
|
||||
// The amount of time it takes for the ripple to arrive at the current pixel position.
|
||||
float delay = distance / speed;
|
||||
|
||||
// Adjust for delay, clamp to 0.
|
||||
time -= delay;
|
||||
time = max(0.0, time);
|
||||
|
||||
// The ripple is a sine wave that Metal scales by an exponential decay
|
||||
// function.
|
||||
float rippleAmount = amplitude * sin(frequency * time) * exp(-decay * time);
|
||||
|
||||
// A vector of length `amplitude` that points away from position.
|
||||
float2 n = normalize(position - origin);
|
||||
|
||||
// Scale `n` by the ripple amount at the current pixel position and add it
|
||||
// to the current pixel position.
|
||||
//
|
||||
// This new position moves toward or away from `origin` based on the
|
||||
// sign and magnitude of `rippleAmount`.
|
||||
float2 newPosition = position + rippleAmount * n;
|
||||
|
||||
// Sample the layer at the new position.
|
||||
half4 color = layer.sample(newPosition);
|
||||
|
||||
// Lighten or darken the color based on the ripple amount and its alpha
|
||||
// component.
|
||||
color.rgb += 0.3 * (rippleAmount / amplitude) * color.a;
|
||||
|
||||
return color;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import VibeviewerModel
|
||||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
@ViewBuilder
|
||||
func applyPreferredColorScheme(_ appearance: VibeviewerModel.AppAppearance) -> some View {
|
||||
switch appearance {
|
||||
case .system:
|
||||
self
|
||||
case .light:
|
||||
self.environment(\.colorScheme, .light)
|
||||
case .dark:
|
||||
self.environment(\.colorScheme, .dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
public extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 3: // RGB (12-bit)
|
||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6: // RGB (24-bit)
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8: // ARGB (32-bit)
|
||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (1, 1, 1, 0)
|
||||
}
|
||||
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
public struct MenuBarExtraTransparencyHelperView: NSViewRepresentable {
|
||||
public init() {}
|
||||
|
||||
public class WindowConfiguratorView: NSView {
|
||||
public override func viewWillDraw() {
|
||||
super.viewWillDraw()
|
||||
self.configure(window: self.window)
|
||||
}
|
||||
public override func viewWillMove(toWindow newWindow: NSWindow?) {
|
||||
super.viewWillMove(toWindow: newWindow)
|
||||
self.configure(window: newWindow)
|
||||
}
|
||||
|
||||
public override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
self.configure(window: self.window)
|
||||
}
|
||||
|
||||
private func configure(window: NSWindow?) {
|
||||
guard let window else { return }
|
||||
|
||||
// Make the underlying Menu Bar Extra panel/window transparent
|
||||
window.styleMask.insert(.fullSizeContentView)
|
||||
window.isOpaque = false
|
||||
window.backgroundColor = .clear
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.hasShadow = true
|
||||
|
||||
guard let contentView = window.contentView else { return }
|
||||
// Ensure content view is fully transparent
|
||||
contentView.wantsLayer = true
|
||||
contentView.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
contentView.layer?.isOpaque = false
|
||||
|
||||
// Clear any default backgrounds across the entire ancestor chain
|
||||
self.clearBackgroundUpwards(from: contentView)
|
||||
|
||||
// If you want translucent blur instead of fully transparent, uncomment the block below
|
||||
// addBlur(in: contentView)
|
||||
}
|
||||
|
||||
private func clearBackgroundRecursively(in view: NSView?) {
|
||||
guard let view else { return }
|
||||
view.wantsLayer = true
|
||||
view.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
view.layer?.isOpaque = false
|
||||
|
||||
if let eff = view as? NSVisualEffectView, eff.identifier?.rawValue != "vv_transparent_blur" {
|
||||
// 移除系统默认的模糊/材质背景视图,确保完全透明
|
||||
eff.removeFromSuperview()
|
||||
return
|
||||
}
|
||||
|
||||
for sub in view.subviews { clearBackgroundRecursively(in: sub) }
|
||||
}
|
||||
|
||||
private func clearBackgroundUpwards(from view: NSView) {
|
||||
var current: NSView? = view
|
||||
while let node = current {
|
||||
clearBackgroundRecursively(in: node)
|
||||
current = node.superview
|
||||
}
|
||||
}
|
||||
|
||||
private func addBlur(in contentView: NSView) {
|
||||
let identifier = NSUserInterfaceItemIdentifier("vv_transparent_blur")
|
||||
if contentView.subviews.contains(where: { $0.identifier == identifier }) { return }
|
||||
|
||||
let blurView = NSVisualEffectView()
|
||||
blurView.identifier = identifier
|
||||
blurView.blendingMode = .withinWindow
|
||||
blurView.state = .active
|
||||
blurView.material = .hudWindow
|
||||
blurView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(blurView, positioned: .below, relativeTo: nil)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
blurView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
blurView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
blurView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
blurView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
public func makeNSView(context: Context) -> WindowConfiguratorView {
|
||||
WindowConfiguratorView()
|
||||
}
|
||||
|
||||
public func updateNSView(_ nsView: WindowConfiguratorView, context: Context) { }
|
||||
}
|
||||
|
||||
public extension View {
|
||||
/// 为 MenuBarExtra 的窗口启用透明背景(并添加系统模糊)。
|
||||
/// 使用方式:将其作为菜单根视图的一个背景层即可。
|
||||
func menuBarExtraTransparentBackground() -> some View {
|
||||
self.background(MenuBarExtraTransparencyHelperView())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
|
||||
extension NSObject {
|
||||
|
||||
/// Swap the given named instance method of the given named class with the given
|
||||
/// named instance method of this class.
|
||||
/// - Parameters:
|
||||
/// - method: The name of the instance method whose implementation will be exchanged.
|
||||
/// - className: The name of the class whose instance method implementation will be exchanged.
|
||||
/// - newMethod: The name of the instance method on this class which will replace the first given method.
|
||||
static func exchange(method: String, in className: String, for newMethod: String) {
|
||||
guard let classRef = objc_getClass(className) as? AnyClass,
|
||||
let original = class_getInstanceMethod(classRef, Selector((method))),
|
||||
let replacement = class_getInstanceMethod(self, Selector((newMethod)))
|
||||
else {
|
||||
fatalError("Could not exchange method \(method) on class \(className).");
|
||||
}
|
||||
|
||||
method_exchangeImplementations(original, replacement);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Custom Window Corner Mask Implementation
|
||||
|
||||
/// Exchange Flag
|
||||
///
|
||||
var __SwiftUIMenuBarExtraPanel___cornerMask__didExchange = false;
|
||||
|
||||
/// Custom Corner Radius
|
||||
///
|
||||
fileprivate let kWindowCornerRadius: CGFloat = 32;
|
||||
|
||||
extension NSObject {
|
||||
|
||||
@objc func __SwiftUIMenuBarExtraPanel___cornerMask() -> NSImage? {
|
||||
let width = kWindowCornerRadius * 2;
|
||||
let height = kWindowCornerRadius * 2;
|
||||
|
||||
let image = NSImage(size: CGSizeMake(width, height));
|
||||
|
||||
image.lockFocus();
|
||||
|
||||
/// Draw a rounded-rectangle corner mask.
|
||||
///
|
||||
NSColor.black.setFill();
|
||||
NSBezierPath(
|
||||
roundedRect: CGRectMake(0, 0, width, height),
|
||||
xRadius: kWindowCornerRadius,
|
||||
yRadius: kWindowCornerRadius).fill();
|
||||
|
||||
image.unlockFocus();
|
||||
|
||||
image.capInsets = .init(
|
||||
top: kWindowCornerRadius,
|
||||
left: kWindowCornerRadius,
|
||||
bottom: kWindowCornerRadius,
|
||||
right: kWindowCornerRadius);
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Context Window Accessor
|
||||
|
||||
public struct MenuBarExtraWindowHelperView: NSViewRepresentable {
|
||||
|
||||
public init() {}
|
||||
|
||||
public class WindowHelper: NSView {
|
||||
|
||||
public override func viewWillDraw() {
|
||||
if __SwiftUIMenuBarExtraPanel___cornerMask__didExchange { return }
|
||||
|
||||
guard
|
||||
let window: AnyObject = self.window,
|
||||
let windowClass = window.className
|
||||
else { return }
|
||||
|
||||
|
||||
NSObject.exchange(
|
||||
method: "_cornerMask",
|
||||
in: windowClass,
|
||||
for: "__SwiftUIMenuBarExtraPanel___cornerMask");
|
||||
|
||||
let _ = window.perform(Selector(("_cornerMaskChanged")));
|
||||
|
||||
__SwiftUIMenuBarExtraPanel___cornerMask__didExchange = true;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func updateNSView(_ nsView: WindowHelper, context: Context) { }
|
||||
|
||||
public func makeNSView(context: Context) -> WindowHelper { WindowHelper() }
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func menuBarExtraWindowCorner() -> some View {
|
||||
self.background(MenuBarExtraWindowHelperView())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
public extension View {
|
||||
func noiseEffect(seed: Float, frequency: Float, amplitude: Float) -> some View {
|
||||
self.modifier(NoiseEffectModifier(seed: seed, frequency: frequency, amplitude: amplitude))
|
||||
}
|
||||
}
|
||||
|
||||
public struct NoiseEffectModifier: ViewModifier {
|
||||
var seed: Float
|
||||
var frequency: Float
|
||||
var amplitude: Float
|
||||
|
||||
public init(seed: Float, frequency: Float, amplitude: Float) {
|
||||
self.seed = seed
|
||||
self.frequency = frequency
|
||||
self.amplitude = amplitude
|
||||
}
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
content
|
||||
.colorEffect(ShaderLibrary.parameterizedNoise(.float(seed), .float(frequency), .float(amplitude)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
func rippleEffect(at origin: CGPoint, isRunning: Bool, interval: TimeInterval) -> some View {
|
||||
self.modifier(RippleEffect(at: origin, isRunning: isRunning, interval: interval))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct RippleEffect: ViewModifier {
|
||||
var origin: CGPoint
|
||||
|
||||
var isRunning: Bool
|
||||
var interval: TimeInterval
|
||||
|
||||
@State private var tick: Int = 0
|
||||
|
||||
init(at origin: CGPoint, isRunning: Bool, interval: TimeInterval) {
|
||||
self.origin = origin
|
||||
self.isRunning = isRunning
|
||||
self.interval = interval
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let origin = origin
|
||||
let animationDuration = animationDuration
|
||||
|
||||
return content
|
||||
.keyframeAnimator(
|
||||
initialValue: 0,
|
||||
trigger: tick
|
||||
) { view, elapsedTime in
|
||||
view.modifier(RippleModifier(
|
||||
origin: origin,
|
||||
elapsedTime: elapsedTime,
|
||||
duration: animationDuration
|
||||
))
|
||||
} keyframes: { _ in
|
||||
MoveKeyframe(0)
|
||||
CubicKeyframe(animationDuration, duration: animationDuration)
|
||||
}
|
||||
.task(id: isRunning ? interval : -1.0) {
|
||||
guard isRunning else { return }
|
||||
while !Task.isCancelled && isRunning {
|
||||
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
||||
if isRunning {
|
||||
tick &+= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var animationDuration: TimeInterval { 3 }
|
||||
}
|
||||
|
||||
struct RippleModifier: ViewModifier {
|
||||
var origin: CGPoint
|
||||
|
||||
var elapsedTime: TimeInterval
|
||||
|
||||
var duration: TimeInterval
|
||||
|
||||
var amplitude: Double = 12
|
||||
var frequency: Double = 15
|
||||
var decay: Double = 8
|
||||
var speed: Double = 1200
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shader = ShaderLibrary.Ripple(
|
||||
.float2(origin),
|
||||
.float(elapsedTime),
|
||||
.float(amplitude),
|
||||
.float(frequency),
|
||||
.float(decay),
|
||||
.float(speed)
|
||||
)
|
||||
|
||||
let maxSampleOffset = maxSampleOffset
|
||||
let elapsedTime = elapsedTime
|
||||
let duration = duration
|
||||
|
||||
content.visualEffect { view, _ in
|
||||
view.layerEffect(
|
||||
shader,
|
||||
maxSampleOffset: maxSampleOffset,
|
||||
isEnabled: 0 < elapsedTime && elapsedTime < duration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var maxSampleOffset: CGSize {
|
||||
CGSize(width: amplitude, height: amplitude)
|
||||
}
|
||||
}
|
||||
|
||||
struct NoiseEffect: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
public extension View {
|
||||
func maxFrame(
|
||||
_ width: Bool = true, _ height: Bool = true, alignment: SwiftUI.Alignment = .center
|
||||
) -> some View {
|
||||
Group {
|
||||
if width, height {
|
||||
frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment)
|
||||
} else if width {
|
||||
frame(maxWidth: .infinity, alignment: alignment)
|
||||
} else if height {
|
||||
frame(maxHeight: .infinity, alignment: alignment)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cornerRadiusWithCorners(
|
||||
_ radius: CGFloat, corners: RectCorner = .allCorners
|
||||
) -> some View {
|
||||
clipShape(RoundedCorner(radius: radius, corners: corners))
|
||||
}
|
||||
|
||||
func linearBorder(
|
||||
color: Color, cornerRadius: CGFloat, lineWidth: CGFloat = 1, from: UnitPoint = .top,
|
||||
to: UnitPoint = .center
|
||||
) -> some View {
|
||||
overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.inset(by: lineWidth)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: color.opacity(0.1), location: 0),
|
||||
.init(color: color.opacity(0.02), location: 0.5),
|
||||
.init(color: color.opacity(0.06), location: 1),
|
||||
], startPoint: from, endPoint: to),
|
||||
lineWidth: lineWidth
|
||||
)
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
func linearBorder(
|
||||
stops: [Gradient.Stop], cornerRadius: CGFloat, lineWidth: CGFloat = 1,
|
||||
from: UnitPoint = .top, to: UnitPoint = .center
|
||||
) -> some View {
|
||||
overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.inset(by: lineWidth)
|
||||
.stroke(
|
||||
LinearGradient(stops: stops, startPoint: from, endPoint: to),
|
||||
lineWidth: lineWidth
|
||||
)
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
func overlayBorder(
|
||||
color: Color,
|
||||
lineWidth: CGFloat = 1,
|
||||
insets: CGFloat = 0,
|
||||
cornerRadius: CGFloat = 0,
|
||||
hidden: Bool = false
|
||||
) -> some View {
|
||||
overlay(
|
||||
RoundedCorner(radius: cornerRadius, corners: .allCorners)
|
||||
.fill(color)
|
||||
.mask(
|
||||
RoundedCorner(radius: cornerRadius, corners: .allCorners)
|
||||
.stroke(style: .init(lineWidth: lineWidth))
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
.padding(insets)
|
||||
)
|
||||
}
|
||||
|
||||
func extendTapGesture(_ value: CGFloat = 8, _ action: @escaping () -> Void) -> some View {
|
||||
self
|
||||
.padding(value)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
action()
|
||||
}
|
||||
.padding(-value)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RectCorner: OptionSet, Sendable {
|
||||
public let rawValue: Int
|
||||
public init(rawValue: Int) { self.rawValue = rawValue }
|
||||
|
||||
public static let topLeft: RectCorner = RectCorner(rawValue: 1 << 0)
|
||||
public static let topRight: RectCorner = RectCorner(rawValue: 1 << 1)
|
||||
public static let bottomLeft: RectCorner = RectCorner(rawValue: 1 << 2)
|
||||
public static let bottomRight: RectCorner = RectCorner(rawValue: 1 << 3)
|
||||
public static let allCorners: RectCorner = [.topLeft, .topRight, .bottomLeft, .bottomRight]
|
||||
}
|
||||
|
||||
struct RoundedCorner: Shape, InsettableShape {
|
||||
var radius: CGFloat
|
||||
var corners: RectCorner = .allCorners
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let topLeft = corners.contains(.topLeft) ? radius : 0
|
||||
let topRight = corners.contains(.topRight) ? radius : 0
|
||||
let bottomLeft = corners.contains(.bottomLeft) ? radius : 0
|
||||
let bottomRight = corners.contains(.bottomRight) ? radius : 0
|
||||
|
||||
if #available(iOS 17.0, macOS 14.0, *) {
|
||||
return UnevenRoundedRectangle(
|
||||
topLeadingRadius: topLeft,
|
||||
bottomLeadingRadius: bottomLeft,
|
||||
bottomTrailingRadius: bottomRight,
|
||||
topTrailingRadius: topRight,
|
||||
style: .continuous
|
||||
).path(in: rect)
|
||||
} else {
|
||||
if corners == .allCorners {
|
||||
return RoundedRectangle(cornerRadius: radius, style: .continuous).path(in: rect)
|
||||
} else {
|
||||
return Path(rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func inset(by amount: CGFloat) -> some InsettableShape {
|
||||
var shape = self
|
||||
shape.radius -= amount
|
||||
return shape
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Testing
|
||||
|
||||
@Test func placeholderCompiles() {
|
||||
#expect(true)
|
||||
}
|
||||
Reference in New Issue
Block a user