InteractiveHoverStyle.swift 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import AppKit
  2. import SwiftUI
  3. // MARK: - Theme Tokens
  4. extension AppTheme {
  5. static let hoverAnimation: Animation = .easeOut(duration: 0.15)
  6. static let hoverFillOpacity: Double = 0.06
  7. static let hoverBorderOpacity: Double = 0.14
  8. static let hoverPressScale: CGFloat = 0.98
  9. static let hoverLiftScale: CGFloat = 1.012
  10. static var hoverFill: Color { Color.white.opacity(hoverFillOpacity) }
  11. static var hoverBorder: Color { Color.white.opacity(hoverBorderOpacity) }
  12. }
  13. // MARK: - Button Styles
  14. struct AppPlainButtonStyle: ButtonStyle {
  15. @State private var isHovered = false
  16. func makeBody(configuration: Configuration) -> some View {
  17. configuration.label
  18. .scaleEffect(
  19. configuration.isPressed ? AppTheme.hoverPressScale : (isHovered ? AppTheme.hoverLiftScale : 1)
  20. )
  21. .brightness(configuration.isPressed ? -0.04 : (isHovered ? 0.05 : 0))
  22. .animation(AppTheme.hoverAnimation, value: isHovered)
  23. .animation(AppTheme.hoverAnimation, value: configuration.isPressed)
  24. .onHover { isHovered = $0 }
  25. .hoverCursor()
  26. }
  27. }
  28. struct AppSecondaryButtonStyle: ButtonStyle {
  29. @State private var isHovered = false
  30. func makeBody(configuration: Configuration) -> some View {
  31. configuration.label
  32. .foregroundStyle(AppTheme.textSecondary)
  33. .padding(.horizontal, 12)
  34. .padding(.vertical, 8)
  35. .background(secondaryBackground(isPressed: configuration.isPressed))
  36. .clipShape(RoundedRectangle(cornerRadius: 8))
  37. .overlay(
  38. RoundedRectangle(cornerRadius: 8)
  39. .stroke(
  40. isHovered || configuration.isPressed
  41. ? AppTheme.hoverBorder
  42. : AppTheme.cardBorder,
  43. lineWidth: 1
  44. )
  45. )
  46. .scaleEffect(configuration.isPressed ? AppTheme.hoverPressScale : (isHovered ? AppTheme.hoverLiftScale : 1))
  47. .animation(AppTheme.hoverAnimation, value: isHovered)
  48. .animation(AppTheme.hoverAnimation, value: configuration.isPressed)
  49. .onHover { isHovered = $0 }
  50. .hoverCursor()
  51. }
  52. private func secondaryBackground(isPressed: Bool) -> Color {
  53. if isPressed {
  54. return AppTheme.cardBackground.opacity(0.55)
  55. }
  56. if isHovered {
  57. return AppTheme.cardBackground.opacity(0.85)
  58. }
  59. return AppTheme.cardBackground
  60. }
  61. }
  62. struct AppPrimaryButtonStyle: ButtonStyle {
  63. @State private var isHovered = false
  64. func makeBody(configuration: Configuration) -> some View {
  65. configuration.label
  66. .brightness(configuration.isPressed ? -0.08 : (isHovered ? 0.08 : 0))
  67. .scaleEffect(configuration.isPressed ? AppTheme.hoverPressScale : (isHovered ? AppTheme.hoverLiftScale : 1))
  68. .animation(AppTheme.hoverAnimation, value: isHovered)
  69. .animation(AppTheme.hoverAnimation, value: configuration.isPressed)
  70. .onHover { isHovered = $0 }
  71. .hoverCursor()
  72. }
  73. }
  74. struct AppTextLinkButtonStyle: ButtonStyle {
  75. @State private var isHovered = false
  76. func makeBody(configuration: Configuration) -> some View {
  77. configuration.label
  78. .foregroundStyle(isHovered ? AppTheme.textPrimary : AppTheme.textSecondary)
  79. .underline(isHovered || configuration.isPressed)
  80. .animation(AppTheme.hoverAnimation, value: isHovered)
  81. .animation(AppTheme.hoverAnimation, value: configuration.isPressed)
  82. .onHover { isHovered = $0 }
  83. .hoverCursor()
  84. }
  85. }
  86. // MARK: - View Modifiers
  87. private struct HoverCursorModifier: ViewModifier {
  88. @State private var isHovered = false
  89. func body(content: Content) -> some View {
  90. content
  91. .onHover { hovering in
  92. isHovered = hovering
  93. if hovering {
  94. NSCursor.pointingHand.push()
  95. } else {
  96. NSCursor.pop()
  97. }
  98. }
  99. }
  100. }
  101. private struct HoverOverlayModifier: ViewModifier {
  102. let cornerRadius: CGFloat
  103. var isEnabled: Bool = true
  104. @State private var isHovered = false
  105. func body(content: Content) -> some View {
  106. content
  107. .overlay {
  108. if isEnabled && isHovered {
  109. RoundedRectangle(cornerRadius: cornerRadius)
  110. .fill(AppTheme.hoverFill)
  111. .allowsHitTesting(false)
  112. }
  113. }
  114. .animation(AppTheme.hoverAnimation, value: isHovered)
  115. .onHover { isHovered = $0 }
  116. }
  117. }
  118. private struct HoverCardModifier: ViewModifier {
  119. let cornerRadius: CGFloat
  120. var isSelected: Bool = false
  121. var isEnabled: Bool = true
  122. @State private var isHovered = false
  123. func body(content: Content) -> some View {
  124. content
  125. .overlay {
  126. if isEnabled && isHovered {
  127. RoundedRectangle(cornerRadius: cornerRadius)
  128. .fill(isSelected ? Color.white.opacity(0.04) : AppTheme.hoverFill)
  129. .allowsHitTesting(false)
  130. }
  131. }
  132. .overlay {
  133. if isEnabled && isHovered {
  134. RoundedRectangle(cornerRadius: cornerRadius)
  135. .stroke(AppTheme.hoverBorder, lineWidth: 1)
  136. .allowsHitTesting(false)
  137. }
  138. }
  139. .scaleEffect(isEnabled && isHovered ? AppTheme.hoverLiftScale : 1)
  140. .animation(AppTheme.hoverAnimation, value: isHovered)
  141. .onHover { isHovered = $0 }
  142. }
  143. }
  144. private struct SidebarRowHoverModifier: ViewModifier {
  145. let isSelected: Bool
  146. let showsCardBackground: Bool
  147. let selectedBorderColor: Color
  148. @State private var isHovered = false
  149. func body(content: Content) -> some View {
  150. content
  151. .background(sidebarRowBackground)
  152. .animation(AppTheme.hoverAnimation, value: isHovered)
  153. .onHover { isHovered = $0 }
  154. }
  155. @ViewBuilder
  156. private var sidebarRowBackground: some View {
  157. if showsCardBackground {
  158. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  159. .fill(AppTheme.quickAccessBackground)
  160. .overlay(
  161. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  162. .stroke(
  163. isSelected
  164. ? selectedBorderColor
  165. : (isHovered ? AppTheme.hoverBorder : AppTheme.cardBorder),
  166. lineWidth: 1
  167. )
  168. )
  169. .overlay {
  170. if isHovered && !isSelected {
  171. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  172. .fill(AppTheme.hoverFill)
  173. }
  174. }
  175. } else if isSelected {
  176. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  177. .fill(Color.white.opacity(0.05))
  178. } else if isHovered {
  179. RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
  180. .fill(AppTheme.hoverFill)
  181. }
  182. }
  183. }
  184. // MARK: - View Extensions
  185. extension View {
  186. func hoverCursor(_ enabled: Bool = true) -> some View {
  187. modifier(HoverCursorEnabledModifier(enabled: enabled))
  188. }
  189. func hoverOverlay(cornerRadius: CGFloat, isEnabled: Bool = true) -> some View {
  190. modifier(HoverOverlayModifier(cornerRadius: cornerRadius, isEnabled: isEnabled))
  191. }
  192. func hoverCard(cornerRadius: CGFloat, isSelected: Bool = false, isEnabled: Bool = true) -> some View {
  193. modifier(HoverCardModifier(cornerRadius: cornerRadius, isSelected: isSelected, isEnabled: isEnabled))
  194. }
  195. func sidebarRowHover(
  196. isSelected: Bool,
  197. showsCardBackground: Bool = false,
  198. selectedBorderColor: Color = .clear
  199. ) -> some View {
  200. modifier(
  201. SidebarRowHoverModifier(
  202. isSelected: isSelected,
  203. showsCardBackground: showsCardBackground,
  204. selectedBorderColor: selectedBorderColor
  205. )
  206. )
  207. }
  208. }
  209. private struct HoverCursorEnabledModifier: ViewModifier {
  210. let enabled: Bool
  211. func body(content: Content) -> some View {
  212. if enabled {
  213. content.modifier(HoverCursorModifier())
  214. } else {
  215. content
  216. }
  217. }
  218. }