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 }