From cb0ef42f4e7691f8e27cba020e54b557dc786454 Mon Sep 17 00:00:00 2001
From: Maksim Grishutin <barredewe@gmail.com>
Date: Mon, 9 Dec 2024 12:38:22 +0300
Subject: [PATCH] Improve settings

---
 SnowfallApp/Common/MenuBarSettings.swift   | 107 +++++++++++----------
 SnowfallApp/Common/MouseTrackingView.swift |   2 +-
 SnowfallApp/Common/RangedSliderView.swift  |  98 +++++++++++++++++++
 SnowfallApp/Common/Settings.swift          |  43 ++++++++-
 SnowfallApp/Common/SnowRenderer.swift      |  74 +++++++++-----
 SnowfallApp/Common/Snowflake.swift         |  26 ++++-
 SnowfallApp/Info.plist                     |   2 +
 SnowfallApp/SnowShader.metal               |  17 ++--
 SnowfallApp/SnowfallApp.swift              |   4 +-
 9 files changed, 288 insertions(+), 85 deletions(-)
 create mode 100644 SnowfallApp/Common/RangedSliderView.swift

diff --git a/SnowfallApp/Common/MenuBarSettings.swift b/SnowfallApp/Common/MenuBarSettings.swift
index 472d35d..eca6dd9 100644
--- a/SnowfallApp/Common/MenuBarSettings.swift
+++ b/SnowfallApp/Common/MenuBarSettings.swift
@@ -1,70 +1,79 @@
 import SwiftUI
 
 struct MenuBarSettings: View {
-    @State var speed: Float = Settings.snowflakeSpeedRange.upperBound
-    @State var size: Float = Settings.snowflakeSizeRange.upperBound
+    @State var speed: ClosedRange<Float> = Settings.snowflakeSpeedRange
+    @State var size: ClosedRange<Float> = Settings.snowflakeSizeRange
     @State var maxSnowflakes: Float = Float(Settings.maxSnowflakes)
+    @State var windowInteraction: Bool = Settings.windowInteraction
+    @State var windStrength: Float = Settings.windStrength * 1000
 
     var body: some View {
-        VStack(spacing: 2) {
-            VStack(alignment: .leading, spacing: 2) {
-                Group {
-                    Row(title: "Snowflake speed: \(speed)")
-                        .foregroundColor(.secondary)
-                    Slider(value: $speed, in: 1...20.0)
-                        .padding(.horizontal)
-                        .padding(.vertical, 4)
-                }
+        VStack(alignment: .leading, spacing: 12) {
+            VStack(alignment: .leading) {
+                Text("Snowflake speed")
+                RangedSliderView(value: $speed, in: 1...20)
+                Divider()
             }
-            VStack(alignment: .leading, spacing: 2) {
-                Group {
-                    Row(title: "Snowflake size: \(size)")
-                        .foregroundColor(.secondary)
-                    Slider(value: $size, in: 4...40.0)
-                        .padding(.horizontal)
-                        .padding(.vertical, 4)
-                }
+
+            VStack(alignment: .leading) {
+                Text("Snowflake size")
+                RangedSliderView(value: $size, in: 3...40)
+                Divider()
+            }
+
+            VStack(alignment: .leading) {
+                Text("Snowflake count \(Int(maxSnowflakes))")
+                Slider(value: $maxSnowflakes, in: 1...4000)
+                Divider()
             }
 
-            VStack(alignment: .leading, spacing: 2) {
-                Group {
-                    Row(title: "Snowflake count: \(maxSnowflakes)")
-                        .foregroundColor(.secondary)
-                    Slider(value: $maxSnowflakes, in: 1...4000.0)
-                        .padding(.horizontal)
-                        .padding(.vertical, 4)
+            VStack(alignment: .leading) {
+                Text("Wind strength \(Int(windStrength / 100))")
+                Slider(value: $windStrength, in: 1...4000)
+                Divider()
+            }
+
+            Toggle("Snow interact with window", isOn: $windowInteraction)
+
+            HStack {
+                Button("Close app") {
+                    NSApplication.shared.terminate(nil)
+                }
+                .buttonStyle(.borderless)
+
+                Spacer()
+
+                Button("Reset", role: .destructive) {
+                    Settings.reset()
+                    speed = Settings.snowflakeSpeedRange
+                    size = Settings.snowflakeSizeRange
+                    maxSnowflakes = Float(Settings.maxSnowflakes)
+                    windowInteraction = Settings.windowInteraction
+                    windStrength = Settings.windStrength * 1000
                 }
             }
         }
         .frame(maxWidth: 250)
-        .padding(.vertical, 4)
-        .onChange(of: speed) { oldValue, newValue in
-            Settings.snowflakeSpeedRange = Settings.snowflakeSpeedRange.lowerBound...newValue
+        .padding()
+        .onChange(of: speed) { _, newValue in
+            Settings.snowflakeSpeedRange = newValue
         }
-        .onChange(of: size) { oldValue, newValue in
-            Settings.snowflakeSizeRange = Settings.snowflakeSizeRange.lowerBound...newValue
+        .onChange(of: size) { _, newValue in
+            Settings.snowflakeSizeRange = newValue
         }
-        .onChange(of: maxSnowflakes) { oldValue, newValue in
+        .onChange(of: maxSnowflakes) { _, newValue in
             Settings.maxSnowflakes = Int(newValue)
         }
+        .onChange(of: windowInteraction) { _, newValue in
+            Settings.windowInteraction = newValue
+        }
+        .onChange(of: windStrength) { _, newValue in
+            Settings.windStrength = newValue / 1000
+        }
     }
 }
 
-struct Row: View {
-    let title : String
-    var hasCheckmark = false
-
-    var body: some View {
-        HStack(spacing: 4) {
-            Group {
-                if hasCheckmark {
-                    Image(systemName: "checkmark")
-                } else {
-                    Text("")
-                }
-            }
-            .frame(width: 12)
-            Text(title)
-        }
-    }
+#Preview {
+    MenuBarSettings()
+        .scaledToFit()
 }
diff --git a/SnowfallApp/Common/MouseTrackingView.swift b/SnowfallApp/Common/MouseTrackingView.swift
index 25329b7..3fa0b6e 100644
--- a/SnowfallApp/Common/MouseTrackingView.swift
+++ b/SnowfallApp/Common/MouseTrackingView.swift
@@ -22,7 +22,7 @@ class MouseTrackingNSView: NSView {
 
         let trackingArea = NSTrackingArea(
             rect: bounds,
-            options: [.activeAlways, .mouseMoved, .inVisibleRect],
+            options: [.activeAlways, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect, .activeWhenFirstResponder],
             owner: self,
             userInfo: nil
         )
diff --git a/SnowfallApp/Common/RangedSliderView.swift b/SnowfallApp/Common/RangedSliderView.swift
new file mode 100644
index 0000000..5028730
--- /dev/null
+++ b/SnowfallApp/Common/RangedSliderView.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+struct RangedSliderView: View {
+    let currentValue: Binding<ClosedRange<Float>>
+    let sliderBounds: ClosedRange<Int>
+
+    public init(value: Binding<ClosedRange<Float>>, in bounds: ClosedRange<Int>) {
+        self.currentValue = value
+        self.sliderBounds = bounds
+    }
+
+    var body: some View {
+        GeometryReader { geomentry in
+            sliderView(sliderSize: geomentry.size)
+
+        }
+        .padding(.init(top: 16, leading: 10, bottom: 6, trailing: 10))
+    }
+
+
+    @ViewBuilder private func sliderView(sliderSize: CGSize) -> some View {
+        let sliderViewYCenter = sliderSize.height / 2
+        ZStack {
+            RoundedRectangle(cornerRadius: 2)
+                .fill(Color.secondary.opacity(0.2))
+                .frame(height: 4)
+                .padding(.horizontal, -10)
+
+            ZStack {
+                let sliderBoundDifference = sliderBounds.count
+                let stepWidthInPixel = CGFloat(sliderSize.width) / CGFloat(sliderBoundDifference)
+
+                // Calculate Left Thumb initial position
+                let leftThumbLocation: CGFloat = currentValue.wrappedValue.lowerBound == Float(sliderBounds.lowerBound)
+                ? 0
+                : CGFloat(currentValue.wrappedValue.lowerBound - Float(sliderBounds.lowerBound)) * stepWidthInPixel
+
+                // Calculate right thumb initial position
+                let rightThumbLocation = CGFloat(currentValue.wrappedValue.upperBound) * stepWidthInPixel
+
+                // Path between both handles
+                lineBetweenThumbs(from: .init(x: leftThumbLocation, y: sliderViewYCenter), to: .init(x: rightThumbLocation, y: sliderViewYCenter))
+
+                // Left Thumb Handle
+                let leftThumbPoint = CGPoint(x: leftThumbLocation, y: sliderViewYCenter)
+                thumbView(position: leftThumbPoint, value: Float(currentValue.wrappedValue.lowerBound))
+                    .highPriorityGesture(DragGesture().onChanged { dragValue in
+
+                        let dragLocation = dragValue.location
+                        let xThumbOffset = min(max(0, dragLocation.x), sliderSize.width)
+
+                        let newValue = Float(sliderBounds.lowerBound) + Float(xThumbOffset / stepWidthInPixel)
+
+                        // Stop the range thumbs from colliding each other
+                        if newValue < currentValue.wrappedValue.upperBound {
+                            currentValue.wrappedValue = newValue...currentValue.wrappedValue.upperBound
+                        }
+                    })
+
+                // Right Thumb Handle
+                thumbView(position: CGPoint(x: rightThumbLocation, y: sliderViewYCenter), value: currentValue.wrappedValue.upperBound)
+                    .highPriorityGesture(DragGesture().onChanged { dragValue in
+                        let dragLocation = dragValue.location
+                        let xThumbOffset = min(max(CGFloat(leftThumbLocation), dragLocation.x), sliderSize.width)
+
+                        var newValue = Float(xThumbOffset / stepWidthInPixel) // convert back the value bound
+                        newValue = min(newValue, Float(sliderBounds.upperBound))
+
+                        // Stop the range thumbs from colliding each other
+                        if newValue > currentValue.wrappedValue.lowerBound {
+                            currentValue.wrappedValue = currentValue.wrappedValue.lowerBound...newValue
+                        }
+                    })
+            }
+        }
+    }
+
+    @ViewBuilder func lineBetweenThumbs(from: CGPoint, to: CGPoint) -> some View {
+        Path { path in
+            path.move(to: from)
+            path.addLine(to: to)
+        }.stroke(Color.accentColor, lineWidth: 4)
+    }
+
+    @ViewBuilder func thumbView(position: CGPoint, value: Float) -> some View {
+        ZStack {
+            Text(String(Int(value)))
+                .font(.system(size: 10, weight: .semibold))
+                .offset(y: -20)
+            Circle()
+                .frame(width: 20, height: 20)
+                .foregroundColor(Color.gray)
+                .shadow(color: Color.black.opacity(0.16), radius: 8, x: 0, y: 2)
+                .contentShape(Rectangle())
+        }
+        .position(x: position.x, y: position.y)
+    }
+}
diff --git a/SnowfallApp/Common/Settings.swift b/SnowfallApp/Common/Settings.swift
index 293252b..a5ace5b 100644
--- a/SnowfallApp/Common/Settings.swift
+++ b/SnowfallApp/Common/Settings.swift
@@ -1,8 +1,47 @@
 import Cocoa
+import SwiftUI
 
 struct Settings {
-    static var snowflakeSizeRange: ClosedRange<Float> = 4...10
+    @AppStorage("snowflakeSizeRange")
+    static var snowflakeSizeRange: ClosedRange<Float> = 3...13
+    @AppStorage("maxSnowflakes")
     static var maxSnowflakes = 1000
+    @AppStorage("snowflakeSpeedRange")
     static var snowflakeSpeedRange: ClosedRange<Float> = 1...3
-    static var windStrength: Float = 0.5
+    @AppStorage("windStrength")
+    static var windStrength: Float = 2.5
+    @AppStorage("semltingSpeed")
+    static var semltingSpeed: Float = 0.05
+    @AppStorage("windowInteraction")
+    static var windowInteraction: Bool = true
+
+    static func reset() {
+        UserDefaults.standard.dictionaryRepresentation().keys.forEach({ UserDefaults.standard.removeObject(forKey: $0) })
+    }
+}
+
+// MARK: - Extensions
+
+extension Float: @retroactive RawRepresentable {
+    public var rawValue: String {
+        String(self)
+    }
+    
+    public typealias RawValue = String
+
+    public init?(rawValue: String) {
+        self = Float(rawValue)!
+    }
+}
+
+extension ClosedRange: @retroactive RawRepresentable where Bound == Float {
+    public init?(rawValue: String) {
+        let split = rawValue.split(separator: "...")
+
+        self.init(uncheckedBounds: (lower: Float(split[0])!, upper: Float(split[1])!))
+    }
+    
+    public var rawValue: String {
+        return String(lowerBound) + "..." + String(upperBound)
+    }
 }
diff --git a/SnowfallApp/Common/SnowRenderer.swift b/SnowfallApp/Common/SnowRenderer.swift
index e87fd14..5387058 100644
--- a/SnowfallApp/Common/SnowRenderer.swift
+++ b/SnowfallApp/Common/SnowRenderer.swift
@@ -7,9 +7,10 @@ class SnowRenderer: NSObject, MTKViewDelegate {
     private var commandQueue: MTLCommandQueue!
     private var particleBuffer: MTLBuffer!
     private var pipelineState: MTLRenderPipelineState!
+
     private var snowflakes: [Snowflake] = []
 
-    var mousePosition: simd_float2 = .zero
+    var mousePosition: simd_float2 = simd_float2(.infinity, .infinity)
     var screenSize: simd_float2 = .zero
 
     private let influenceRadius: Float = 50.0
@@ -83,8 +84,14 @@ class SnowRenderer: NSObject, MTKViewDelegate {
 
         let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
         renderEncoder.setRenderPipelineState(pipelineState)
+
+        // Snowflakes
         renderEncoder.setVertexBuffer(particleBuffer, offset: 0, index: 0)
+
+        // ScreenSize
         renderEncoder.setVertexBytes(&screenSize, length: MemoryLayout<SIMD2<Float>>.stride, index: 1)
+        renderEncoder.setFragmentBytes(&screenSize, length: MemoryLayout<SIMD2<Float>>.stride, index: 1)
+
         renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: Settings.maxSnowflakes)
         renderEncoder.endEncoding()
 
@@ -96,6 +103,12 @@ class SnowRenderer: NSObject, MTKViewDelegate {
 
     func updateSnowflakes() {
         let radius = influenceRadius
+        var activeWindowRect: CGRect?
+
+        if Settings.windowInteraction {
+            activeWindowRect = WindowInfo().getActiveWindowRect()
+        }
+
         for i in 0..<Settings.maxSnowflakes {
             var snowflake = snowflakes[i]
             let dx = mousePosition.x - snowflake.position.x
@@ -112,9 +125,16 @@ class SnowRenderer: NSObject, MTKViewDelegate {
             }
 
             snowflake.position.y += snowflake.velocity.y
-            snowflake.position.x += Float.random(in: 0.1...1) * Settings.windStrength
+            snowflake.position.x += Float.random(in: 0.1...0.5) * Settings.windStrength
+
+            if Settings.windowInteraction,
+               let activeWindowRect,
+               activeWindowRect.contains(CGPoint(x: CGFloat(snowflake.position.x), y: CGFloat(snowflake.position.y))) {
+                snowflake.position.y = Float(activeWindowRect.origin.y)
+                snowflake.size -= Settings.semltingSpeed
+            }
 
-            if snowflake.position.y - snowflake.size > screenSize.y {
+            if (snowflake.position.y - snowflake.size > screenSize.y) || !(snowflake.size > Settings.snowflakeSizeRange.lowerBound - 1) {
                 snowflake.clear(for: screenSize)
             }
 
@@ -128,24 +148,34 @@ class SnowRenderer: NSObject, MTKViewDelegate {
     }
 }
 
-// MARK: Extensions
-
-extension simd_float2 {
-    static var velocity: simd_float2 {
-        simd_float2(Float.random(in: -1...1), Float.random(in: Settings.snowflakeSpeedRange))
-    }
-}
-
-extension Float {
-    static var size: Float {
-        Float.random(in: Settings.snowflakeSizeRange)
-    }
-}
-
-extension simd_float4 {
-    static func color(for size: Float) -> simd_float4 {
-        let normalizedSize = (size - Settings.snowflakeSizeRange.lowerBound) / (Settings.snowflakeSizeRange.upperBound - Settings.snowflakeSizeRange.lowerBound)
-        let opacity = Swift.max(0.1, 1.0 - normalizedSize * 0.8)
-        return simd_float4(1.0, 1.0, 1.0, opacity)
+import Cocoa
+import CoreGraphics
+
+class WindowInfo {
+    let statusBarSize = 38.0
+
+    func getActiveWindowRect() -> CGRect? {
+        let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
+        let windowListInfo = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
+
+        for windowInfo in windowListInfo {
+            guard let layer = windowInfo[kCGWindowLayer as String] as? Int,
+                  layer == 0,
+//                  let ownerName = windowInfo[kCGWindowOwnerName as String] as? String,
+                  let windowBounds = windowInfo[kCGWindowBounds as String] as? [String: Any],
+                  let x = windowBounds["X"] as? CGFloat,
+                  let y = windowBounds["Y"] as? CGFloat,
+                  let width = windowBounds["Width"] as? CGFloat,
+//                  let height = windowBounds["Height"] as? CGFloat,
+                  !(x == 0 && (y == statusBarSize || y == 0)), width >= 50 else { continue }
+
+//            print("App: \(ownerName)")
+//            print("Position: (\(x), \(y))")
+//            print("Size: \(width)x\(height)")
+//            print("----------------------")
+
+            return CGRect(x: x, y: y, width: width, height: 50.0)
+        }
+        return nil
     }
 }
diff --git a/SnowfallApp/Common/Snowflake.swift b/SnowfallApp/Common/Snowflake.swift
index 4b4f81c..1893351 100644
--- a/SnowfallApp/Common/Snowflake.swift
+++ b/SnowfallApp/Common/Snowflake.swift
@@ -12,7 +12,7 @@ struct Snowflake {
         size = .size
         color = .color(for: size)
 
-        position = simd_float2(Float.random(in: 0...screenSize.x), Float.random(in: 0...screenSize.y))
+        position = simd_float2(Float.random(in: -(200 * Settings.windStrength)...screenSize.x), Float.random(in: 0...screenSize.y))
     }
 
     mutating func clear(for screenSize: simd_float2) {
@@ -21,6 +21,28 @@ struct Snowflake {
         color = .color(for: size)
 
         position.y = -size
-        position.x = Float.random(in: 0...screenSize.x)
+        position.x = Float.random(in: -(200 * Settings.windStrength)...screenSize.x)
+    }
+}
+
+// MARK: Extensions
+
+extension simd_float2 {
+    static var velocity: simd_float2 {
+        simd_float2(Float.random(in: -1...1), Float.random(in: Settings.snowflakeSpeedRange))
+    }
+}
+
+extension Float {
+    static var size: Float {
+        Float.random(in: Settings.snowflakeSizeRange)
+    }
+}
+
+extension simd_float4 {
+    static func color(for size: Float) -> simd_float4 {
+        let normalizedSize = (size - Settings.snowflakeSizeRange.lowerBound) / (Settings.snowflakeSizeRange.upperBound - Settings.snowflakeSizeRange.lowerBound)
+        let opacity = Swift.max(0.1, 1.0 - normalizedSize * 0.8)
+        return simd_float4(1.0, 1.0, 1.0, opacity)
     }
 }
diff --git a/SnowfallApp/Info.plist b/SnowfallApp/Info.plist
index 4c31b49..2cde7ef 100644
--- a/SnowfallApp/Info.plist
+++ b/SnowfallApp/Info.plist
@@ -2,6 +2,8 @@
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
+	<key>LSUIElement</key>
+	<false/>
 	<key>NSFullScreenModeAllScreens</key>
 	<true/>
 	<key>NSHighResolutionCapable</key>
diff --git a/SnowfallApp/SnowShader.metal b/SnowfallApp/SnowShader.metal
index fe8aad7..1457c30 100644
--- a/SnowfallApp/SnowShader.metal
+++ b/SnowfallApp/SnowShader.metal
@@ -14,19 +14,20 @@ struct VertexOut {
     float4 color;
 };
 
+float2 convert_to_metal_coordinates(float2 point, float2 viewSize) {
+    float2 inverseViewSize = 1 / viewSize;
+    return float2((2.0f * point.x * inverseViewSize.x) - 1.0f, (2.0f * -point.y * inverseViewSize.y) + 1.0f);
+}
+
 vertex VertexOut vertex_main(const device Snowflake *snowflakes [[buffer(0)]],
                              constant float2 &screenSize[[buffer(1)]],
                              uint vertexID [[vertex_id]]) {
-    VertexOut out;
-
-    float2 position = snowflakes[vertexID].position;
+    float2 position = convert_to_metal_coordinates(snowflakes[vertexID].position, screenSize);
     float size = snowflakes[vertexID].size;
     float4 color = snowflakes[vertexID].color;
 
-    out.position = float4(position.x / screenSize.x * 2.0 - 1.0,
-                          (1.0 - position.y / screenSize.y) * 2.0 - 1.0,
-                          0.0,
-                          1.0);
+    VertexOut out;
+    out.position = float4(position, 0, 1);
     out.pointSize = size;
     out.color = color;
 
@@ -34,7 +35,7 @@ vertex VertexOut vertex_main(const device Snowflake *snowflakes [[buffer(0)]],
 }
 
 fragment float4 fragment_main(VertexOut fragData [[stage_in]],
-                             float2 pointCoord  [[point_coord]]) {
+                              float2 pointCoord  [[point_coord]]) {
     if (length(pointCoord - float2(0.5)) > 0.5) {
         discard_fragment();
     }
diff --git a/SnowfallApp/SnowfallApp.swift b/SnowfallApp/SnowfallApp.swift
index c192aa3..bf542bb 100644
--- a/SnowfallApp/SnowfallApp.swift
+++ b/SnowfallApp/SnowfallApp.swift
@@ -16,6 +16,8 @@ struct SnowfallAppApp: App {
     }
 }
 
+import CoreGraphics
+
 class AppDelegate: NSObject, NSApplicationDelegate {
     func applicationDidFinishLaunching(_ notification: Notification) {
         guard let window = NSApplication.shared.windows.first else { return }
@@ -33,7 +35,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
         window.styleMask.remove(.resizable)
         window.styleMask = [.borderless]
         window.setFrame(NSScreen.main?.frame ?? .zero, display: true)
-        window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+        window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle, .transient, .stationary]
         window.makeKeyAndOrderFront(nil)
         window.ignoresMouseEvents = true
     }