|
@@ -0,0 +1,248 @@
|
|
|
|
|
+import AppKit
|
|
|
|
|
+import SwiftUI
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Theme Tokens
|
|
|
|
|
+
|
|
|
|
|
+extension AppTheme {
|
|
|
|
|
+ static let hoverAnimation: Animation = .easeOut(duration: 0.15)
|
|
|
|
|
+ static let hoverFillOpacity: Double = 0.06
|
|
|
|
|
+ static let hoverBorderOpacity: Double = 0.14
|
|
|
|
|
+ static let hoverPressScale: CGFloat = 0.98
|
|
|
|
|
+ static let hoverLiftScale: CGFloat = 1.012
|
|
|
|
|
+
|
|
|
|
|
+ static var hoverFill: Color { Color.white.opacity(hoverFillOpacity) }
|
|
|
|
|
+ static var hoverBorder: Color { Color.white.opacity(hoverBorderOpacity) }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Button Styles
|
|
|
|
|
+
|
|
|
|
|
+struct AppPlainButtonStyle: ButtonStyle {
|
|
|
|
|
+ @State private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ func makeBody(configuration: Configuration) -> some View {
|
|
|
|
|
+ configuration.label
|
|
|
|
|
+ .scaleEffect(
|
|
|
|
|
+ configuration.isPressed ? AppTheme.hoverPressScale : (isHovered ? AppTheme.hoverLiftScale : 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ .brightness(configuration.isPressed ? -0.04 : (isHovered ? 0.05 : 0))
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: isHovered)
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: configuration.isPressed)
|
|
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
|
|
+ .hoverCursor()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+struct AppSecondaryButtonStyle: ButtonStyle {
|
|
|
|
|
+ @State private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ func makeBody(configuration: Configuration) -> some View {
|
|
|
|
|
+ configuration.label
|
|
|
|
|
+ .foregroundStyle(AppTheme.textSecondary)
|
|
|
|
|
+ .padding(.horizontal, 12)
|
|
|
|
|
+ .padding(.vertical, 8)
|
|
|
|
|
+ .background(secondaryBackground(isPressed: configuration.isPressed))
|
|
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 8))
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 8)
|
|
|
|
|
+ .stroke(
|
|
|
|
|
+ isHovered || configuration.isPressed
|
|
|
|
|
+ ? AppTheme.hoverBorder
|
|
|
|
|
+ : AppTheme.cardBorder,
|
|
|
|
|
+ lineWidth: 1
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .scaleEffect(configuration.isPressed ? AppTheme.hoverPressScale : (isHovered ? AppTheme.hoverLiftScale : 1))
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: isHovered)
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: configuration.isPressed)
|
|
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
|
|
+ .hoverCursor()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func secondaryBackground(isPressed: Bool) -> Color {
|
|
|
|
|
+ if isPressed {
|
|
|
|
|
+ return AppTheme.cardBackground.opacity(0.55)
|
|
|
|
|
+ }
|
|
|
|
|
+ if isHovered {
|
|
|
|
|
+ return AppTheme.cardBackground.opacity(0.85)
|
|
|
|
|
+ }
|
|
|
|
|
+ return AppTheme.cardBackground
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+struct AppPrimaryButtonStyle: ButtonStyle {
|
|
|
|
|
+ @State private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ func makeBody(configuration: Configuration) -> some View {
|
|
|
|
|
+ configuration.label
|
|
|
|
|
+ .brightness(configuration.isPressed ? -0.08 : (isHovered ? 0.08 : 0))
|
|
|
|
|
+ .scaleEffect(configuration.isPressed ? AppTheme.hoverPressScale : (isHovered ? AppTheme.hoverLiftScale : 1))
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: isHovered)
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: configuration.isPressed)
|
|
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
|
|
+ .hoverCursor()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+struct AppTextLinkButtonStyle: ButtonStyle {
|
|
|
|
|
+ @State private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ func makeBody(configuration: Configuration) -> some View {
|
|
|
|
|
+ configuration.label
|
|
|
|
|
+ .foregroundStyle(isHovered ? AppTheme.textPrimary : AppTheme.textSecondary)
|
|
|
|
|
+ .underline(isHovered || configuration.isPressed)
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: isHovered)
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: configuration.isPressed)
|
|
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
|
|
+ .hoverCursor()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - View Modifiers
|
|
|
|
|
+
|
|
|
|
|
+private struct HoverCursorModifier: ViewModifier {
|
|
|
|
|
+ @State private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ func body(content: Content) -> some View {
|
|
|
|
|
+ content
|
|
|
|
|
+ .onHover { hovering in
|
|
|
|
|
+ isHovered = hovering
|
|
|
|
|
+ if hovering {
|
|
|
|
|
+ NSCursor.pointingHand.push()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ NSCursor.pop()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct HoverOverlayModifier: ViewModifier {
|
|
|
|
|
+ let cornerRadius: CGFloat
|
|
|
|
|
+ var isEnabled: Bool = true
|
|
|
|
|
+ @State private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ func body(content: Content) -> some View {
|
|
|
|
|
+ content
|
|
|
|
|
+ .overlay {
|
|
|
|
|
+ if isEnabled && isHovered {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: cornerRadius)
|
|
|
|
|
+ .fill(AppTheme.hoverFill)
|
|
|
|
|
+ .allowsHitTesting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: isHovered)
|
|
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct HoverCardModifier: ViewModifier {
|
|
|
|
|
+ let cornerRadius: CGFloat
|
|
|
|
|
+ var isSelected: Bool = false
|
|
|
|
|
+ var isEnabled: Bool = true
|
|
|
|
|
+ @State private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ func body(content: Content) -> some View {
|
|
|
|
|
+ content
|
|
|
|
|
+ .overlay {
|
|
|
|
|
+ if isEnabled && isHovered {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: cornerRadius)
|
|
|
|
|
+ .fill(isSelected ? Color.white.opacity(0.04) : AppTheme.hoverFill)
|
|
|
|
|
+ .allowsHitTesting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .overlay {
|
|
|
|
|
+ if isEnabled && isHovered {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: cornerRadius)
|
|
|
|
|
+ .stroke(AppTheme.hoverBorder, lineWidth: 1)
|
|
|
|
|
+ .allowsHitTesting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .scaleEffect(isEnabled && isHovered ? AppTheme.hoverLiftScale : 1)
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: isHovered)
|
|
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct SidebarRowHoverModifier: ViewModifier {
|
|
|
|
|
+ let isSelected: Bool
|
|
|
|
|
+ let showsCardBackground: Bool
|
|
|
|
|
+ let selectedBorderColor: Color
|
|
|
|
|
+ @State private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ func body(content: Content) -> some View {
|
|
|
|
|
+ content
|
|
|
|
|
+ .background(sidebarRowBackground)
|
|
|
|
|
+ .animation(AppTheme.hoverAnimation, value: isHovered)
|
|
|
|
|
+ .onHover { isHovered = $0 }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @ViewBuilder
|
|
|
|
|
+ private var sidebarRowBackground: some View {
|
|
|
|
|
+ if showsCardBackground {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
|
|
|
|
|
+ .fill(AppTheme.quickAccessBackground)
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
|
|
|
|
|
+ .stroke(
|
|
|
|
|
+ isSelected
|
|
|
|
|
+ ? selectedBorderColor
|
|
|
|
|
+ : (isHovered ? AppTheme.hoverBorder : AppTheme.cardBorder),
|
|
|
|
|
+ lineWidth: 1
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay {
|
|
|
|
|
+ if isHovered && !isSelected {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
|
|
|
|
|
+ .fill(AppTheme.hoverFill)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if isSelected {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
|
|
|
|
|
+ .fill(Color.white.opacity(0.05))
|
|
|
|
|
+ } else if isHovered {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
|
|
|
|
|
+ .fill(AppTheme.hoverFill)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - View Extensions
|
|
|
|
|
+
|
|
|
|
|
+extension View {
|
|
|
|
|
+ func hoverCursor(_ enabled: Bool = true) -> some View {
|
|
|
|
|
+ modifier(HoverCursorEnabledModifier(enabled: enabled))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func hoverOverlay(cornerRadius: CGFloat, isEnabled: Bool = true) -> some View {
|
|
|
|
|
+ modifier(HoverOverlayModifier(cornerRadius: cornerRadius, isEnabled: isEnabled))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func hoverCard(cornerRadius: CGFloat, isSelected: Bool = false, isEnabled: Bool = true) -> some View {
|
|
|
|
|
+ modifier(HoverCardModifier(cornerRadius: cornerRadius, isSelected: isSelected, isEnabled: isEnabled))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func sidebarRowHover(
|
|
|
|
|
+ isSelected: Bool,
|
|
|
|
|
+ showsCardBackground: Bool = false,
|
|
|
|
|
+ selectedBorderColor: Color = .clear
|
|
|
|
|
+ ) -> some View {
|
|
|
|
|
+ modifier(
|
|
|
|
|
+ SidebarRowHoverModifier(
|
|
|
|
|
+ isSelected: isSelected,
|
|
|
|
|
+ showsCardBackground: showsCardBackground,
|
|
|
|
|
+ selectedBorderColor: selectedBorderColor
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private struct HoverCursorEnabledModifier: ViewModifier {
|
|
|
|
|
+ let enabled: Bool
|
|
|
|
|
+
|
|
|
|
|
+ func body(content: Content) -> some View {
|
|
|
|
|
+ if enabled {
|
|
|
|
|
+ content.modifier(HoverCursorModifier())
|
|
|
|
|
+ } else {
|
|
|
|
|
+ content
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|