├── README.md ├── Sources ├── Lowtech │ ├── Lowtech.swift │ ├── Overrides.swift │ ├── Constants.swift │ ├── AtomicUtil.swift │ ├── Settings.swift │ ├── Crypto.swift │ ├── Notifications.swift │ ├── PanelWindow.swift │ ├── ViewHelpers.swift │ ├── AXUtil.swift │ ├── TaskUtil.swift │ ├── EventMonitor.swift │ ├── Logs.swift │ ├── LowtechPopover.swift │ ├── NanoID.swift │ ├── ProcessUtil.swift │ ├── AnimationManager.swift │ ├── Colors.swift │ ├── LowtechAppDelegate.swift │ ├── StatusBarController.swift │ ├── Hashids.swift │ ├── OSDWindow.swift │ ├── Util.swift │ ├── Styles.swift │ └── Components.swift ├── LowtechAppStore │ ├── Numbers.swift.secret │ ├── Dates.swift │ └── AppStoreViews.swift ├── LowtechPro │ ├── Settings.swift │ ├── ProViews.swift │ └── Pro.swift └── LowtechIndie │ ├── LowtechUpdates.swift │ └── IndieViews.swift ├── enc.fish.secret ├── enc.swift.secret ├── rsa.swift.secret ├── .gitsecret ├── keys │ ├── pubring.kbx │ ├── trustdb.gpg │ └── pubring.kbx~ └── paths │ └── mapping.cfg ├── h-lowtech.sublime-project ├── .pre-commit.sh ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── .swiftformat ├── Package.swift └── Package.resolved /README.md: -------------------------------------------------------------------------------- 1 | # Lowtech 2 | 3 | A description of this package. 4 | -------------------------------------------------------------------------------- /Sources/Lowtech/Lowtech.swift: -------------------------------------------------------------------------------- 1 | public struct Lowtech { 2 | public init() {} 3 | } 4 | -------------------------------------------------------------------------------- /enc.fish.secret: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FuzzyIdeas/Lowtech/HEAD/enc.fish.secret -------------------------------------------------------------------------------- /enc.swift.secret: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FuzzyIdeas/Lowtech/HEAD/enc.swift.secret -------------------------------------------------------------------------------- /rsa.swift.secret: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FuzzyIdeas/Lowtech/HEAD/rsa.swift.secret -------------------------------------------------------------------------------- /.gitsecret/keys/pubring.kbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FuzzyIdeas/Lowtech/HEAD/.gitsecret/keys/pubring.kbx -------------------------------------------------------------------------------- /.gitsecret/keys/trustdb.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FuzzyIdeas/Lowtech/HEAD/.gitsecret/keys/trustdb.gpg -------------------------------------------------------------------------------- /.gitsecret/keys/pubring.kbx~: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FuzzyIdeas/Lowtech/HEAD/.gitsecret/keys/pubring.kbx~ -------------------------------------------------------------------------------- /Sources/LowtechAppStore/Numbers.swift.secret: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FuzzyIdeas/Lowtech/HEAD/Sources/LowtechAppStore/Numbers.swift.secret -------------------------------------------------------------------------------- /Sources/LowtechPro/Settings.swift: -------------------------------------------------------------------------------- 1 | import Defaults 2 | 3 | public extension Defaults.Keys { 4 | static let paddleConsent = Key("paddleConsent", default: false) 5 | } 6 | -------------------------------------------------------------------------------- /h-lowtech.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "folder_exclude_patterns": [ 5 | ".build", 6 | ".swiftpm" 7 | ], 8 | "path": "." 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /Sources/Lowtech/Overrides.swift: -------------------------------------------------------------------------------- 1 | import Path 2 | import Sauce 3 | 4 | public typealias CustomFilePath = Path 5 | public func p(_ string: String) -> CustomFilePath? { 6 | CustomFilePath(string) 7 | } 8 | 9 | public typealias SauceKey = Key 10 | -------------------------------------------------------------------------------- /.pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | which git-format-staged >/dev/null 2>/dev/null || npm install --global git-format-staged 4 | which swiftformat >/dev/null 2>/dev/null || brew install swiftformat 5 | 6 | git-format-staged --formatter "swiftformat stdin --stdinpath '{}'" "*.swift" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .gitsecret/keys/random_seed 9 | !*.secret 10 | enc.swift 11 | enc.fish 12 | Sources/LowtechAppStore/Numbers.swift 13 | rsa.swift 14 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.gitsecret/paths/mapping.cfg: -------------------------------------------------------------------------------- 1 | enc.swift:9156112d00c7db646cc2c5f6882a379f82daa2da7cec1f461212868e407138c6 2 | enc.fish:01456dafcb5d67ee4cd523c81c1f5cc59cf57e7aa0a539db5f856a552c50c172 3 | Sources/LowtechAppStore/Numbers.swift:16015357ac5a28ebc4f5206802b650bc0c55082c5809190e66165715770e6edb 4 | rsa.swift:30ab5da79325657cd44ecb25da320ffa4c4406594b64c01ec66cfcf760c03efa 5 | -------------------------------------------------------------------------------- /Sources/Lowtech/Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Regex 3 | 4 | public let BUNDLE_IDENTIFIER_PATTERN = #"([^:]+):(\d+)"#.r! 5 | public let ALPHANUMERICS = ( 6 | CharacterSet.decimalDigits.characters().filter(\.isASCII) + CharacterSet.lowercaseLetters.characters() 7 | .filter(\.isASCII) 8 | ).map { String($0) } 9 | public let ALPHANUMERICS_SET = Set(ALPHANUMERICS) 10 | -------------------------------------------------------------------------------- /Sources/LowtechAppStore/Dates.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftDate 3 | 4 | @inline(__always) 5 | public func localTimeSince(_ date: Date) -> TimeInterval { 6 | DateInRegion().convertTo(region: .local) - date.convertTo(region: .local) 7 | } 8 | 9 | @inline(__always) 10 | public func localTimeUntil(_ date: Date) -> TimeInterval { 11 | date.convertTo(region: .local) - DateInRegion().convertTo(region: .local) 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Lowtech/AtomicUtil.swift: -------------------------------------------------------------------------------- 1 | import Atomics 2 | 3 | // MARK: - Atomic 4 | 5 | @propertyWrapper 6 | public struct Atomic where Value.AtomicRepresentation.Value == Value { 7 | public init(wrappedValue: Value) { 8 | value = ManagedAtomic(wrappedValue) 9 | } 10 | 11 | public var wrappedValue: Value { 12 | get { value.load(ordering: .relaxed) } 13 | set { value.store(newValue, ordering: .sequentiallyConsistent) } 14 | } 15 | 16 | var value: ManagedAtomic 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Lowtech/Settings.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Combine 3 | import Defaults 4 | import Foundation 5 | import Magnet 6 | 7 | public extension Defaults.Keys { 8 | static let popoverClosed = Key("popoverClosed", default: true) 9 | static let hideMenubarIcon = Key("hideMenubarIcon", default: false) 10 | static let launchCount = Key("launchCount", default: 0) 11 | } 12 | 13 | public func first(this: T, other _: T) -> T { 14 | this 15 | } 16 | 17 | public func last(this _: T, other: T) -> T { 18 | other 19 | } 20 | -------------------------------------------------------------------------------- /Sources/LowtechIndie/LowtechUpdates.swift: -------------------------------------------------------------------------------- 1 | import Defaults 2 | import Lowtech 3 | import Sparkle 4 | 5 | // MARK: - UpdateCheckInterval 6 | 7 | enum UpdateCheckInterval: Int { 8 | case daily = 86400 9 | case everyThreeDays = 259_200 10 | case weekly = 604_800 11 | } 12 | 13 | extension Defaults.Keys { 14 | static let silentUpdates = Key("SUAutomaticallyUpdate", default: false) 15 | static let checkForUpdates = Key("SUEnableAutomaticChecks", default: true) 16 | static let updateCheckInterval = Key("SUScheduledCheckInterval", default: 86400) 17 | } 18 | 19 | // MARK: - LowtechIndieAppDelegate 20 | 21 | open class LowtechIndieAppDelegate: LowtechAppDelegate, SPUUpdaterDelegate, SPUStandardUserDriverDelegate { 22 | public lazy var updateController = initUpdater() 23 | 24 | func initUpdater() -> SPUStandardUpdaterController { 25 | SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: self, userDriverDelegate: self) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Lowtech/Crypto.swift: -------------------------------------------------------------------------------- 1 | import CryptoKit 2 | import Foundation 3 | import Security 4 | 5 | public extension String { 6 | var sha1: String { 7 | var s = Insecure.SHA1() 8 | s.update(data: data(using: .utf8)!) 9 | return s.finalize().hexEncodedString() 10 | } 11 | } 12 | 13 | public extension Bundle { 14 | var isTestFlight: Bool { 15 | var status = noErr 16 | 17 | var code: SecStaticCode? 18 | status = SecStaticCodeCreateWithPath(bundleURL as CFURL, [], &code) 19 | guard status == noErr, let code else { 20 | return false 21 | } 22 | 23 | var requirement: SecRequirement? 24 | status = SecRequirementCreateWithString( 25 | "anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.25.1]" as CFString, 26 | [], &requirement 27 | ) 28 | guard status == noErr, let requirement else { 29 | return false 30 | } 31 | 32 | return SecStaticCodeCheckValidity(code, [], requirement) == errSecSuccess 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Lowtech/Notifications.swift: -------------------------------------------------------------------------------- 1 | import UserNotifications 2 | 3 | public func notify(identifier: String, title: String, body: String) { 4 | let sendNotification = { (nc: UNUserNotificationCenter) in 5 | let content = UNMutableNotificationContent() 6 | content.title = title 7 | content.body = body 8 | content.sound = UNNotificationSound.default 9 | nc.add( 10 | UNNotificationRequest(identifier: identifier, content: content, trigger: nil), 11 | withCompletionHandler: nil 12 | ) 13 | } 14 | 15 | let nc = UNUserNotificationCenter.current() 16 | nc.getNotificationSettings { settings in 17 | mainAsync { 18 | let enabled = settings.alertSetting == .enabled 19 | guard enabled else { 20 | nc.requestAuthorization(options: [], completionHandler: { granted, _ in 21 | guard granted else { return } 22 | sendNotification(nc) 23 | }) 24 | return 25 | } 26 | sendNotification(nc) 27 | } 28 | } 29 | } 30 | 31 | public func removeNotifications(withIdentifiers ids: [String]) { 32 | UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) 33 | } 34 | -------------------------------------------------------------------------------- /Sources/LowtechPro/ProViews.swift: -------------------------------------------------------------------------------- 1 | import Defaults 2 | import Lowtech 3 | import SwiftUI 4 | 5 | // MARK: - LicenseView 6 | 7 | public struct LicenseView: View { 8 | public init(pro: LowtechPro) { 9 | self.pro = pro 10 | } 11 | 12 | public var body: some View { 13 | HStack { 14 | Text("Licence:") 15 | .font(.system(size: 12, weight: .medium, design: .monospaced)) 16 | Text(pro.onTrial ? "trial" : (pro.productActivated ? "active" : "inactive")) 17 | .font(.system(size: 12, weight: .semibold, design: .monospaced)) 18 | 19 | Spacer() 20 | 21 | if pro.onTrial { 22 | Button("Buy") { pro.showCheckout() } 23 | .buttonStyle(FlatButton()) 24 | .font(.system(size: 12, weight: .semibold)) 25 | } 26 | Button((pro.productActivated && !pro.onTrial) ? "Manage" : "Activate") { pro.showLicenseActivation() } 27 | .buttonStyle(FlatButton()) 28 | .font(.system(size: 12, weight: .semibold)) 29 | } 30 | .foregroundColor(.secondary) 31 | .padding(.horizontal, 8) 32 | .padding(.vertical, 8) 33 | .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.primary.opacity(0.1))) 34 | .padding(.top, 10) 35 | } 36 | 37 | @ObservedObject var pro: LowtechPro 38 | @Environment(\.colors) var colors 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Lowtech/PanelWindow.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - PanelWindow 6 | 7 | open class PanelWindow: LowtechWindow { 8 | public convenience init(swiftuiView: AnyView, screen: NSScreen? = nil, corner: ScreenCorner? = nil) { 9 | self.init(contentViewController: NSHostingController(rootView: swiftuiView)) 10 | 11 | screenPlacement = screen 12 | screenCorner = corner 13 | 14 | level = .floating 15 | setAccessibilityRole(.popover) 16 | setAccessibilitySubrole(.unknown) 17 | 18 | backgroundColor = .clear 19 | contentView?.bg = .clear 20 | isOpaque = false 21 | hasShadow = false 22 | styleMask = [.fullSizeContentView] 23 | hidesOnDeactivate = false 24 | isMovableByWindowBackground = true 25 | } 26 | 27 | override open var canBecomeKey: Bool { true } 28 | 29 | open func show(at point: NSPoint? = nil, animate: Bool = false, activate: Bool = true, corner: ScreenCorner? = nil, margin: CGFloat? = nil, screen: NSScreen? = nil) { 30 | if let corner { 31 | moveToScreen(screen, corner: corner, margin: margin, animate: animate) 32 | } else if let point { 33 | withAnim(animate: animate) { w in w.setFrameOrigin(point) } 34 | } else { 35 | withAnim(animate: animate) { w in w.center() } 36 | } 37 | 38 | wc.showWindow(nil) 39 | makeKeyAndOrderFront(nil) 40 | orderFrontRegardless() 41 | if activate { 42 | NSApp.activate(ignoringOtherApps: true) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --allman false 2 | --assetliterals visual-width 3 | --beforemarks 4 | --binarygrouping 4,8 5 | --categorymark "MARK: %c" 6 | --classthreshold 0 7 | --closingparen balanced 8 | --commas always 9 | --conflictmarkers reject 10 | --decimalgrouping 3,6 11 | --elseposition same-line 12 | --emptybraces no-space 13 | --enumthreshold 0 14 | --exponentcase lowercase 15 | --exponentgrouping disabled 16 | --extensionacl on-extension 17 | --extensionlength 0 18 | --extensionmark "MARK: - %t + %c" 19 | --fractiongrouping disabled 20 | --fragment false 21 | --funcattributes preserve 22 | --groupedextension "MARK: %c" 23 | --guardelse auto 24 | --header ignore 25 | --hexgrouping 4,8 26 | --hexliteralcase uppercase 27 | --ifdef indent 28 | --importgrouping alpha 29 | --indent 4 30 | --indentcase false 31 | --lifecycle 32 | --linebreaks lf 33 | --markextensions always 34 | --marktypes always 35 | --maxwidth 240 36 | --modifierorder 37 | --nevertrailing 38 | --nospaceoperators 39 | --nowrapoperators 40 | --octalgrouping 4,8 41 | --operatorfunc spaced 42 | --organizetypes class,enum,struct 43 | --patternlet hoist 44 | --ranges spaced 45 | --redundanttype inferred 46 | --self remove 47 | --selfrequired 48 | --semicolons inline 49 | --shortoptionals always 50 | --smarttabs enabled 51 | --structthreshold 0 52 | --tabwidth unspecified 53 | --trailingclosures 54 | --trimwhitespace always 55 | --typeattributes preserve 56 | --typemark "MARK: - %t" 57 | --varattributes preserve 58 | --voidtype void 59 | --wraparguments before-first 60 | --wrapcollections before-first 61 | --wrapconditions after-first 62 | --wrapparameters before-first 63 | --wrapreturntype preserve 64 | --xcodeindentation disabled 65 | --yodaswap always 66 | --disable unusedArguments 67 | --enable markTypes,organizeDeclarations 68 | -------------------------------------------------------------------------------- /Sources/Lowtech/ViewHelpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // MARK: - ChildSizeReader 5 | 6 | struct ChildSizeReader: View { 7 | @Binding var size: CGSize 8 | 9 | let content: () -> Content 10 | 11 | var body: some View { 12 | content().background( 13 | GeometryReader { proxy in 14 | Color.clear.preference( 15 | key: SizePreferenceKey.self, 16 | value: proxy.size 17 | ) 18 | } 19 | ) 20 | .onPreferenceChange(SizePreferenceKey.self) { preferences in 21 | size = preferences 22 | } 23 | } 24 | } 25 | 26 | // MARK: - SizePreferenceKey 27 | 28 | struct SizePreferenceKey: PreferenceKey { 29 | typealias Value = CGSize 30 | 31 | static var defaultValue: Value = .zero 32 | 33 | static func reduce(value _: inout Value, nextValue: () -> Value) { 34 | _ = nextValue() 35 | } 36 | } 37 | 38 | extension AnyView { 39 | var state: State { State(initialValue: self) } 40 | } 41 | 42 | extension ExpressibleByNilLiteral { 43 | var state: State { State(initialValue: self) } 44 | } 45 | 46 | extension Color { 47 | var state: State { State(initialValue: self) } 48 | } 49 | 50 | extension BinaryInteger { 51 | var state: State { State(initialValue: self) } 52 | } 53 | 54 | extension FloatingPoint { 55 | var state: State { State(initialValue: self) } 56 | } 57 | 58 | extension AnyHashable { 59 | var state: State { State(initialValue: self) } 60 | } 61 | 62 | extension String { 63 | var state: State { State(initialValue: self) } 64 | } 65 | 66 | func st(_ v: T) -> State { 67 | State(initialValue: v) 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Lowtech/AXUtil.swift: -------------------------------------------------------------------------------- 1 | // import AXSwift 2 | 3 | // struct AXWindow { 4 | // // MARK: Lifecycle 5 | // 6 | // init?(from window: UIElement, runningApp: NSRunningApplication? = nil) { 7 | // guard let attrs = try? window.getMultipleAttributes( 8 | // .frame, 9 | // .fullScreen, 10 | // .title, 11 | // .position, 12 | // .main, 13 | // .minimized, 14 | // .size, 15 | // .identifier, 16 | // .subrole, 17 | // .role, 18 | // .focused 19 | // ) 20 | // else { 21 | // return nil 22 | // } 23 | // 24 | // let frame = attrs[.frame] as? NSRect ?? NSRect() 25 | // 26 | // self.frame = frame 27 | // fullScreen = attrs[.fullScreen] as? Bool ?? false 28 | // title = attrs[.title] as? String ?? "" 29 | // position = attrs[.position] as? NSPoint ?? NSPoint() 30 | // main = attrs[.main] as? Bool ?? false 31 | // minimized = attrs[.minimized] as? Bool ?? false 32 | // focused = attrs[.focused] as? Bool ?? false 33 | // size = attrs[.size] as? NSSize ?? NSSize() 34 | // identifier = attrs[.identifier] as? String ?? "" 35 | // subrole = attrs[.subrole] as? String ?? "" 36 | // role = attrs[.role] as? String ?? "" 37 | // 38 | // self.runningApp = runningApp 39 | // } 40 | // 41 | // // MARK: Internal 42 | // 43 | // let frame: NSRect 44 | // let fullScreen: Bool 45 | // let title: String 46 | // let position: NSPoint 47 | // let main: Bool 48 | // let minimized: Bool 49 | // let focused: Bool 50 | // let size: NSSize 51 | // let identifier: String 52 | // let subrole: String 53 | // let role: String 54 | // let runningApp: NSRunningApplication? 55 | // } 56 | // 57 | // extension NSRunningApplication { 58 | // func windows() -> [AXWindow]? { 59 | // guard let app = Application(self) else { return nil } 60 | // do { 61 | // let wins = try app.windows() 62 | // return wins?.compactMap { AXWindow(from: $0, runningApp: self) } 63 | // } catch { 64 | // log.error("Can't get windows for app \(self): \(error)") 65 | // return nil 66 | // } 67 | // } 68 | // } 69 | -------------------------------------------------------------------------------- /Sources/Lowtech/TaskUtil.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public func mainActor(_ action: @escaping @MainActor () -> Void) { 5 | Task.init { await MainActor.run { action() }} 6 | } 7 | 8 | // MARK: - Repeater 9 | 10 | public class Repeater { 11 | public init( 12 | every interval: TimeInterval, 13 | times: Int = 0, 14 | name: String? = nil, 15 | maxDuration: TimeInterval? = nil, 16 | tolerance: TimeInterval? = nil, 17 | runloop: RunLoop = .main, 18 | onFinish: (() -> Void)? = nil, 19 | onCancel: (() -> Void)? = nil, 20 | action: @escaping () -> Void 21 | ) { 22 | let name = name ?? "every \(interval) seconds for \(times) times" 23 | let startTime = Date() 24 | var counter = 0 25 | 26 | self.name = name 27 | self.onCancel = onCancel 28 | 29 | timer = Timer.publish(every: interval, tolerance: tolerance, on: runloop, in: .default) 30 | task = timer 31 | .autoconnect() 32 | .sink { [weak self] d in 33 | if counter == 0 { 34 | debug("Starting repeater '\(name)'") 35 | } 36 | 37 | counter += 1 38 | if let maxDuration, startTime.distance(to: d) > maxDuration { 39 | debug("Repeater finished on maxDuration '\(name)'") 40 | self?.stop() 41 | onFinish?() 42 | return 43 | } 44 | guard times <= 0 || counter < times else { 45 | debug("Repeater finished on maxRunCount '\(name)'") 46 | self?.stop() 47 | onFinish?() 48 | return 49 | } 50 | action() 51 | } 52 | } 53 | 54 | deinit { 55 | #if DEBUG 56 | debug("Deinit repeater '\(self.name)'") 57 | #endif 58 | 59 | stop() 60 | onCancel?() 61 | } 62 | 63 | public func stop() { 64 | let name = name 65 | #if DEBUG 66 | debug("Stopping repeater '\(name)'") 67 | #endif 68 | 69 | stopped = true 70 | task?.cancel() 71 | timer.connect().cancel() 72 | } 73 | 74 | var task: Cancellable? 75 | var onCancel: (() -> Void)? 76 | let timer: Timer.TimerPublisher 77 | let name: String 78 | var stopped = false 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Lowtech/EventMonitor.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | var logGlobalEvents = false 4 | 5 | // MARK: - GlobalEventMonitor 6 | 7 | @MainActor 8 | open class GlobalEventMonitor { 9 | public init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) { 10 | self.mask = mask 11 | self.handler = handler 12 | } 13 | 14 | deinit { 15 | Task.init { await MainActor.run { stop() } } 16 | } 17 | 18 | public func start() { 19 | #if DEBUG 20 | monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: { event in 21 | if event.type == .keyDown || event.type == .flagsChanged { 22 | if event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.control, .option, .shift] { 23 | logGlobalEvents = true 24 | } else if event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.control, .command, .shift] { 25 | logGlobalEvents = false 26 | } 27 | } 28 | if logGlobalEvents { 29 | print("[GLOBAL] Handling mask \(self.mask) on event: \(event)") 30 | } 31 | self.handler(event) 32 | }) as! NSObject 33 | #else 34 | monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) as! NSObject 35 | #endif 36 | } 37 | 38 | public func stop() { 39 | if monitor != nil { 40 | NSEvent.removeMonitor(monitor!) 41 | monitor = nil 42 | } 43 | } 44 | 45 | private var monitor: Any? 46 | private let mask: NSEvent.EventTypeMask 47 | private let handler: (NSEvent?) -> Void 48 | } 49 | 50 | // MARK: - LocalEventMonitor 51 | 52 | @MainActor 53 | open class LocalEventMonitor { 54 | public init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent) -> NSEvent?) { 55 | self.mask = mask 56 | self.handler = handler 57 | } 58 | 59 | deinit { 60 | Task.init { await MainActor.run { stop() } } 61 | } 62 | 63 | public func start() { 64 | monitor = NSEvent.addLocalMonitorForEvents(matching: mask, handler: handler) as! NSObject 65 | } 66 | 67 | public func stop() { 68 | if monitor != nil { 69 | NSEvent.removeMonitor(monitor!) 70 | monitor = nil 71 | } 72 | } 73 | 74 | private var monitor: Any? 75 | private let mask: NSEvent.EventTypeMask 76 | private let handler: (NSEvent) -> NSEvent? 77 | } 78 | -------------------------------------------------------------------------------- /Sources/LowtechAppStore/AppStoreViews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStoreViews.swift 3 | // 4 | // 5 | // Created by Alin Panaitiu on 02.09.2022. 6 | // 7 | 8 | import Foundation 9 | import Lowtech 10 | import SwiftUI 11 | import VisualEffects 12 | 13 | // MARK: - LowtechAppStoreDelegate 14 | 15 | open class LowtechAppStoreDelegate: LowtechAppDelegate { 16 | @MainActor 17 | @inline(__always) 18 | open func trialExpired() -> Bool { 19 | false 20 | } 21 | 22 | @MainActor 23 | @inline(__always) 24 | open func hideTrialOSD() { 25 | guard trialMode, trialExpired() else { 26 | return 27 | } 28 | trialOSD.ignoresMouseEvents = true 29 | trialOSD.alphaValue = 0 30 | } 31 | 32 | @MainActor 33 | @inline(__always) 34 | open func toggleTrialOSD() { 35 | if trialOSD.alphaValue > 0 { 36 | hideTrialOSD() 37 | } else { 38 | showTrialOSD() 39 | } 40 | } 41 | 42 | @MainActor 43 | @inline(__always) 44 | open func showTrialOSD() { 45 | guard trialMode, trialExpired() else { 46 | return 47 | } 48 | trialOSD.ignoresMouseEvents = false 49 | trialOSD.show(closeAfter: 0, fadeAfter: 0, offCenter: 0, centerWindow: false, corner: .bottomRight, screen: .main) 50 | } 51 | 52 | public lazy var trialOSD = { 53 | let w = OSDWindow(swiftuiView: TrialOSDContainer().any) 54 | w.alphaValue = 0 55 | return w 56 | }() 57 | } 58 | 59 | // MARK: - TrialOSDContainer 60 | 61 | public struct TrialOSDContainer: View { 62 | public init() {} 63 | 64 | public var body: some View { 65 | HStack { 66 | if let img = NSImage(named: NSImage.applicationIconName) { 67 | Image(nsImage: img) 68 | .resizable() 69 | .frame(width: 90, height: 90) 70 | } 71 | VStack(alignment: .leading) { 72 | Text("Trial period of") + Text(" \(Bundle.main.name ?? "the app") ").bold() + Text("expired for the current session.") 73 | Text("Buy the full version from") + Text(" App Store ").bold() + Text("to remove this limitation.") 74 | 75 | HStack { 76 | if let url = LowtechAppDelegate.instance.appStoreURL { 77 | Button("Go to App Store") { NSWorkspace.shared.open(url) } 78 | .buttonStyle(FlatButton(color: .blue, textColor: .white)) 79 | } 80 | Button("Quit app") { NSApp.terminate(nil) } 81 | .buttonStyle(FlatButton(color: Colors.red, textColor: .white)) 82 | } 83 | }.fixedSize() 84 | } 85 | .padding() 86 | .background( 87 | VisualEffectBlur(material: .hudWindow, blendingMode: .withinWindow, state: .active) 88 | .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) 89 | .shadow(radius: 6, x: 0, y: 3) 90 | ) 91 | .padding() 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/LowtechIndie/IndieViews.swift: -------------------------------------------------------------------------------- 1 | import Defaults 2 | import Lowtech 3 | import Sparkle 4 | import SwiftUI 5 | 6 | // MARK: - VersionView 7 | 8 | public struct VersionView: View { 9 | public init(updater: SPUUpdater) { 10 | self.updater = updater 11 | } 12 | 13 | public var body: some View { 14 | VStack(alignment: .leading) { 15 | HStack { 16 | Text("Version:") 17 | .font(.system(size: 11, weight: .medium, design: .monospaced)) 18 | Text(Bundle.main.version) 19 | .font(.system(size: 11, weight: .semibold, design: .monospaced)) 20 | 21 | Spacer() 22 | 23 | Button("Check for updates") { updater.checkForUpdates() } 24 | .buttonStyle(FlatButton()) 25 | .font(.system(size: 11, weight: .semibold)) 26 | } 27 | HStack(spacing: 3) { 28 | Text("Check automatically") 29 | .font(.system(size: 11, weight: .medium, design: .monospaced)) 30 | Spacer() 31 | 32 | Button("Never") { 33 | checkForUpdates = false 34 | updateCheckInterval = 0 35 | } 36 | .buttonStyle(PickerButton(horizontalPadding: 6, verticalPadding: 3, enumValue: $updateCheckInterval, onValue: 0)) 37 | .font(.system(size: 11, weight: .semibold)) 38 | Button("Daily") { 39 | checkForUpdates = true 40 | updateCheckInterval = UpdateCheckInterval.daily.rawValue 41 | } 42 | .buttonStyle(PickerButton( 43 | horizontalPadding: 6, 44 | verticalPadding: 3, 45 | enumValue: $updateCheckInterval, 46 | onValue: UpdateCheckInterval.daily.rawValue 47 | )) 48 | .font(.system(size: 11, weight: .semibold)) 49 | Button("Weekly") { 50 | checkForUpdates = true 51 | updateCheckInterval = UpdateCheckInterval.weekly.rawValue 52 | } 53 | .buttonStyle(PickerButton( 54 | horizontalPadding: 6, 55 | verticalPadding: 3, 56 | enumValue: $updateCheckInterval, 57 | onValue: UpdateCheckInterval.weekly.rawValue 58 | )) 59 | .font(.system(size: 11, weight: .semibold)) 60 | } 61 | } 62 | .foregroundColor(.secondary) 63 | .padding(.horizontal, 8) 64 | .padding(.vertical, 8) 65 | .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.primary.opacity(0.1))) 66 | .padding(.top, 10) 67 | } 68 | 69 | @Default(.checkForUpdates) var checkForUpdates 70 | @Default(.updateCheckInterval) var updateCheckInterval 71 | 72 | @ObservedObject var updater: SPUUpdater 73 | @Environment(\.colors) var colors 74 | } 75 | 76 | extension Bundle { 77 | var version: String { 78 | (infoDictionary?["CFBundleVersion"] as? String) ?? "1.0.0" 79 | } 80 | } 81 | 82 | // MARK: - SPUUpdater + ObservableObject 83 | 84 | extension SPUUpdater: ObservableObject {} 85 | -------------------------------------------------------------------------------- /Sources/Lowtech/Logs.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os 3 | 4 | #if DEBUG 5 | @inline(__always) public func debug(_ message: @autoclosure @escaping () -> String) { 6 | log.oslog.debug("\(message())") 7 | } 8 | 9 | @inline(__always) public func trace(_ message: @autoclosure @escaping () -> String) { 10 | log.oslog.trace("\(message())") 11 | } 12 | 13 | @inline(__always) public func err(_ message: @autoclosure @escaping () -> String) { 14 | log.oslog.critical("\(message())") 15 | } 16 | #else 17 | @inline(__always) public func trace(_: @autoclosure () -> String) {} 18 | @inline(__always) public func debug(_: @autoclosure () -> String) {} 19 | @inline(__always) public func err(_: @autoclosure () -> String) {} 20 | #endif 21 | 22 | // MARK: - SwiftyLogger 23 | 24 | public final class SwiftyLogger { 25 | @inline(__always) public class func verbose(_ message: String, context: Any? = "") { 26 | #if DEBUG 27 | oslog.trace("🫥 \(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 28 | #else 29 | oslog.trace("\(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 30 | #endif 31 | } 32 | 33 | @inline(__always) public class func debug(_ message: String, context: Any? = "") { 34 | #if DEBUG 35 | oslog.debug("🌲 \(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 36 | #else 37 | oslog.debug("\(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 38 | #endif 39 | } 40 | 41 | @inline(__always) public class func info(_ message: String, context: Any? = "") { 42 | #if DEBUG 43 | oslog.info("💠 \(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 44 | #else 45 | oslog.info("\(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 46 | #endif 47 | } 48 | 49 | @inline(__always) public class func warning(_ message: String, context: Any? = "") { 50 | #if DEBUG 51 | oslog.warning("🦧 \(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 52 | #else 53 | oslog.warning("\(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 54 | #endif 55 | } 56 | 57 | @inline(__always) public class func error(_ message: String, context: Any? = "") { 58 | #if DEBUG 59 | oslog.fault("👹 \(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 60 | #else 61 | oslog.fault("\(message, privacy: .public) \(String(describing: context ?? ""), privacy: .public)") 62 | #endif 63 | } 64 | 65 | @inline(__always) public class func traceCalls() { 66 | traceLog.trace("\(Thread.callStackSymbols.joined(separator: "\n"), privacy: .public)") 67 | } 68 | 69 | static let oslog = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.lowtechguys.Logger", category: "default") 70 | static let traceLog = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.lowtechguys.Logger", category: "trace") 71 | } 72 | 73 | public let log = SwiftyLogger.self 74 | -------------------------------------------------------------------------------- /Sources/Lowtech/LowtechPopover.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Defaults 3 | import Foundation 4 | 5 | // MARK: - LowtechPopover 6 | 7 | open class LowtechPopover: NSPopover { 8 | public init(_ statusBar: StatusBarController?) { 9 | super.init() 10 | self.statusBar = statusBar 11 | mockView = NSView(frame: NSRect(x: 0, y: 0, width: 2, height: 2)) 12 | mockView?.bg = .clear 13 | 14 | mockWindow = NSWindow( 15 | contentRect: .init(x: 0, y: 0, width: 2, height: 2), 16 | styleMask: [.borderless, .fullSizeContentView], 17 | backing: .buffered, 18 | defer: true 19 | ) 20 | mockWindow?.contentView = mockView 21 | mockWindow?.level = .statusBar 22 | mockWindow?.collectionBehavior = [.stationary, .canJoinAllSpaces, .ignoresCycle, .fullScreenDisallowsTiling] 23 | mockWindow?.sharingType = .none 24 | mockWindow?.ignoresMouseEvents = true 25 | mockWindow?.setAccessibilityRole(.menuBarItem) 26 | mockWindow?.setAccessibilitySubrole(.unknown) 27 | 28 | mockWindow?.backgroundColor = .clear 29 | mockWindow?.isOpaque = false 30 | mockWindow?.hasShadow = false 31 | mockWindow?.hidesOnDeactivate = false 32 | mockWindowController = NSWindowController(window: mockWindow) 33 | } 34 | 35 | public required init?(coder: NSCoder) { 36 | super.init(coder: coder) 37 | } 38 | 39 | override open func close() { 40 | super.close() 41 | mockWindow?.close() 42 | } 43 | 44 | open func show(menubarIconHidden: Bool? = nil) { 45 | if menubarIconHidden ?? Defaults[.hideMenubarIcon] { 46 | if let frame = NSScreen.main?.visibleFrame { 47 | let maxAllowedWindowX = (frame.width + frame.origin.x) - 400 48 | showAt(point: NSPoint(x: maxAllowedWindowX, y: frame.maxY - 30)) 49 | } else { 50 | showAt() 51 | } 52 | } else if let button = statusBar?.statusItem.button { 53 | show(relativeTo: button.frame, of: button, preferredEdge: .minY) 54 | } 55 | } 56 | 57 | open func showAt(point: NSPoint? = nil, preferredEdge: NSRectEdge = .maxY) { 58 | guard let mockWindow, let mockWindowController, let view = mockWindow.contentView else { 59 | return 60 | } 61 | 62 | if let point { 63 | mockWindow.setFrameOrigin(point) 64 | } else { 65 | mockWindow.center() 66 | } 67 | mockWindowController.showWindow(self) 68 | mockWindow.makeKeyAndOrderFront(self) 69 | mockWindow.orderFrontRegardless() 70 | show(relativeTo: view.bounds, of: view, preferredEdge: preferredEdge) 71 | } 72 | 73 | weak var statusBar: StatusBarController? 74 | 75 | private var mockWindow: NSWindow? 76 | private var mockWindowController: NSWindowController? 77 | private var mockView: NSView? 78 | } 79 | 80 | public extension NSPoint { 81 | static func mouseLocation(centeredOn window: NSWindow? = nil) -> NSPoint { 82 | let loc = NSEvent.mouseLocation 83 | if let window { 84 | return loc.applying(.init(translationX: window.frame.width / -2, y: window.frame.height / -2)) 85 | } 86 | return loc 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/Lowtech/NanoID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NanoID.swift 3 | // 4 | // Created by Anton Lovchikov on 05/07/2018. 5 | // Copyright © 2018 Anton Lovchikov. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - NanoID 11 | 12 | /// USAGE 13 | /// 14 | /// Nano ID with default alphabet (0-9a-zA-Z_~) and length (21 chars) 15 | /// let id = NanoID.new() 16 | /// 17 | /// Nano ID with default alphabet and given length 18 | /// let id = NanoID.new(12) 19 | /// 20 | /// Nano ID with given alphabet and length 21 | /// let id = NanoID.new(alphabet: .uppercasedLatinLetters, size: 15) 22 | /// 23 | /// Nano ID with preset custom parameters 24 | /// let nanoID = NanoID(alphabet: .lowercasedLatinLetters,.numbers, size:10) 25 | /// let idFirst = nanoID.new() 26 | /// let idSecond = nanoID.new() 27 | 28 | public class NanoID { 29 | /// Inits an instance with Shared Parameters 30 | public init(alphabet: NanoIDAlphabet..., size: Int) { 31 | self.size = size 32 | self.alphabet = NanoIDHelper.parse(alphabet) 33 | } 34 | 35 | /// Generates a Nano ID using Default Parameters 36 | public static func new() -> String { 37 | NanoIDHelper.generate(from: defaultAphabet, of: defaultSize) 38 | } 39 | 40 | /// Generates a Nano ID using given occasional parameters 41 | public static func new(alphabet: NanoIDAlphabet..., size: Int) -> String { 42 | let charactersString = NanoIDHelper.parse(alphabet) 43 | return NanoIDHelper.generate(from: charactersString, of: size) 44 | } 45 | 46 | /// Generates a Nano ID using Default Alphabet and given size 47 | public static func new(_ size: Int) -> String { 48 | NanoIDHelper.generate(from: NanoID.defaultAphabet, of: size) 49 | } 50 | 51 | /// Generates a Nano ID using Shared Parameters 52 | public func new() -> String { 53 | NanoIDHelper.generate(from: alphabet, of: size) 54 | } 55 | 56 | // Default Parameters 57 | private static let defaultSize = 21 58 | private static let defaultAphabet = NanoIDAlphabet.urlSafe.toString() 59 | 60 | // Shared Parameters 61 | private var size: Int 62 | private var alphabet: String 63 | } 64 | 65 | // MARK: - NanoIDHelper 66 | 67 | private enum NanoIDHelper { 68 | /// Parses input alphabets into a string 69 | static func parse(_ alphabets: [NanoIDAlphabet]) -> String { 70 | var stringCharacters = "" 71 | 72 | for alphabet in alphabets { 73 | stringCharacters.append(alphabet.toString()) 74 | } 75 | 76 | return stringCharacters 77 | } 78 | 79 | /// Generates a Nano ID using given parameters 80 | static func generate(from alphabet: String, of length: Int) -> String { 81 | var nanoID = "" 82 | 83 | for _ in 0 ..< length { 84 | let randomCharacter = NanoIDHelper.randomCharacter(from: alphabet) 85 | nanoID.append(randomCharacter) 86 | } 87 | 88 | return nanoID 89 | } 90 | 91 | /// Returns a random character from a given string 92 | static func randomCharacter(from string: String) -> Character { 93 | let randomNum = arc4random_uniform(string.count.u32).i 94 | let randomIndex = string.index(string.startIndex, offsetBy: randomNum) 95 | return string[randomIndex] 96 | } 97 | } 98 | 99 | // MARK: - NanoIDAlphabet 100 | 101 | public enum NanoIDAlphabet { 102 | case urlSafe 103 | case uppercasedLatinLetters 104 | case lowercasedLatinLetters 105 | case numbers 106 | 107 | public func toString() -> String { 108 | switch self { 109 | case .uppercasedLatinLetters, .lowercasedLatinLetters, .numbers: 110 | chars() 111 | case .urlSafe: 112 | "\(NanoIDAlphabet.uppercasedLatinLetters.chars())\(NanoIDAlphabet.lowercasedLatinLetters.chars())\(NanoIDAlphabet.numbers.chars())~_" 113 | 114 | } 115 | } 116 | 117 | private func chars() -> String { 118 | switch self { 119 | case .uppercasedLatinLetters: 120 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 121 | case .lowercasedLatinLetters: 122 | "abcdefghijklmnopqrstuvwxyz" 123 | case .numbers: 124 | "1234567890" 125 | default: 126 | "" 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /Sources/Lowtech/ProcessUtil.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - ProcessStatus 4 | 5 | public struct ProcessStatus { 6 | var output: Data? 7 | var error: Data? 8 | var success: Bool 9 | 10 | var o: String? { 11 | output?.s?.trimmed 12 | } 13 | 14 | var e: String? { 15 | error?.s?.trimmed 16 | } 17 | } 18 | 19 | public func stdout(of process: Process) -> Data? { 20 | let stdout = process.standardOutput as! FileHandle 21 | try? stdout.close() 22 | 23 | guard let path = process.environment?["__swift_stdout"], 24 | let stdoutFile = FileHandle(forReadingAtPath: path) else { return nil } 25 | if #available(macOS 10.15.4, *) { 26 | return try! stdoutFile.readToEnd() 27 | } else { 28 | return stdoutFile.readDataToEndOfFile() 29 | } 30 | } 31 | 32 | public func stderr(of process: Process) -> Data? { 33 | let stderr = process.standardOutput as! FileHandle 34 | try? stderr.close() 35 | 36 | guard let path = process.environment?["__swift_stderr"], 37 | let stderrFile = FileHandle(forReadingAtPath: path) else { return nil } 38 | if #available(macOS 10.15.4, *) { 39 | return try! stderrFile.readToEnd() 40 | } else { 41 | return stderrFile.readDataToEndOfFile() 42 | } 43 | } 44 | 45 | @inline(__always) public var fm: FileManager { 46 | FileManager.default 47 | } 48 | 49 | public func shellProc(_ launchPath: String = "/bin/zsh", args: [String], env: [String: String]? = nil) -> Process? { 50 | let outputDir = try! fm.url( 51 | for: .itemReplacementDirectory, 52 | in: .userDomainMask, 53 | appropriateFor: fm.homeDirectoryForCurrentUser, 54 | create: true 55 | ) 56 | 57 | let stdoutFilePath = outputDir.appendingPathComponent("stdout").path 58 | fm.createFile(atPath: stdoutFilePath, contents: nil, attributes: nil) 59 | 60 | let stderrFilePath = outputDir.appendingPathComponent("stderr").path 61 | fm.createFile(atPath: stderrFilePath, contents: nil, attributes: nil) 62 | 63 | guard let stdoutFile = FileHandle(forWritingAtPath: stdoutFilePath), 64 | let stderrFile = FileHandle(forWritingAtPath: stderrFilePath) 65 | else { 66 | return nil 67 | } 68 | 69 | let task = Process() 70 | task.standardOutput = stdoutFile 71 | task.standardError = stderrFile 72 | task.launchPath = launchPath 73 | task.arguments = args 74 | 75 | var env = env ?? ProcessInfo.processInfo.environment 76 | env["__swift_stdout"] = stdoutFilePath 77 | env["__swift_stderr"] = stderrFilePath 78 | task.environment = env 79 | 80 | do { 81 | try task.run() 82 | } catch { 83 | err("Error running \(launchPath) \(args): \(error)") 84 | return nil 85 | } 86 | 87 | return task 88 | } 89 | 90 | public func shell( 91 | _ launchPath: String = "/bin/zsh", 92 | command: String, 93 | timeout: TimeInterval? = nil, 94 | env: [String: String]? = nil, 95 | wait: Bool = true 96 | ) -> ProcessStatus { 97 | shell(launchPath, args: ["-c", command], timeout: timeout, env: env, wait: wait) 98 | } 99 | 100 | public func shell( 101 | _ launchPath: String = "/bin/zsh", 102 | args: [String], 103 | timeout: TimeInterval? = nil, 104 | env: [String: String]? = nil, 105 | wait: Bool = true 106 | ) -> ProcessStatus { 107 | guard let task = shellProc(launchPath, args: args, env: env) else { 108 | return ProcessStatus(output: nil, error: nil, success: false) 109 | } 110 | 111 | guard wait else { 112 | return ProcessStatus( 113 | output: nil, 114 | error: nil, 115 | success: true 116 | ) 117 | } 118 | 119 | guard let timeout else { 120 | task.waitUntilExit() 121 | return ProcessStatus( 122 | output: stdout(of: task), 123 | error: stderr(of: task), 124 | success: task.terminationStatus == 0 125 | ) 126 | } 127 | 128 | let result = asyncNow { 129 | task.waitUntilExit() 130 | }.wait(for: timeout) 131 | if result == .timedOut { 132 | task.terminate() 133 | } 134 | 135 | return ProcessStatus( 136 | output: stdout(of: task), 137 | error: stderr(of: task), 138 | success: task.terminationStatus == 0 139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /Sources/Lowtech/AnimationManager.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Combine 3 | import Defaults 4 | import Foundation 5 | import SwiftUI 6 | 7 | // MARK: - AnimationSpeed 8 | 9 | public enum AnimationSpeed: String, Codable, Defaults.Serializable { 10 | case fluid 11 | case snappy 12 | case instant 13 | 14 | public var animation: Animation { 15 | switch self { 16 | case .fluid: 17 | .jumpySpring 18 | case .snappy: 19 | .quickSpring 20 | case .instant: 21 | .fastSpring 22 | } 23 | } 24 | 25 | public var multiplier: Double { 26 | switch self { 27 | case .fluid: 28 | 0.5 29 | case .snappy: 30 | 1.25 31 | case .instant: 32 | 1.75 33 | } 34 | } 35 | } 36 | 37 | public extension Defaults.Keys { 38 | static let allowAnimationsInLPM = Key("allowAnimationsInLPM", default: false) 39 | static let animationSpeed = Key("animationSpeed", default: .snappy) 40 | static let allowAnimations = Key("allowAnimations", default: true) 41 | } 42 | 43 | // MARK: - AnimationManager 44 | 45 | public class AnimationManager: ObservableObject, ObservableSettings { 46 | init() { 47 | initObservers() 48 | } 49 | 50 | @MainActor public static let shared = AnimationManager() 51 | 52 | public static var isLowPowerModeEnabled: Bool { 53 | if #available(macOS 12.0, *) { 54 | ProcessInfo.processInfo.isLowPowerModeEnabled 55 | } else { 56 | false 57 | } 58 | } 59 | 60 | public var observers: Set = [] 61 | public var apply = true 62 | 63 | @Setting(.animationSpeed) public var animationSpeed 64 | @Published public var shouldAnimate = Defaults[.allowAnimations] && !shouldReduceMotion() 65 | @Published public var animation = Defaults[.allowAnimations] && !shouldReduceMotion() ? Defaults[.animationSpeed].animation : .linear(duration: 0) 66 | 67 | @Published public var allowAnimationsInLPM = Defaults[.allowAnimationsInLPM] { 68 | didSet { 69 | reduceMotion = Self.shouldReduceMotion() 70 | } 71 | } 72 | 73 | @Published public var reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion || isLowPowerModeEnabled { 74 | didSet { 75 | animation = Defaults[.allowAnimations] && !reduceMotion ? Defaults[.animationSpeed].animation : .linear(duration: 0) 76 | shouldAnimate = Defaults[.allowAnimations] && !reduceMotion 77 | } 78 | } 79 | 80 | @Published public var lowPowerMode = isLowPowerModeEnabled { 81 | didSet { 82 | reduceMotion = Self.shouldReduceMotion() 83 | } 84 | } 85 | 86 | public static func shouldReduceMotion() -> Bool { 87 | if !Defaults[.allowAnimationsInLPM], isLowPowerModeEnabled { 88 | return true 89 | } 90 | return NSWorkspace.shared.accessibilityDisplayShouldReduceMotion 91 | } 92 | 93 | public func initObservers() { 94 | bind( 95 | .animationSpeed, 96 | property: \.animation, 97 | transformer: .init(to: { Defaults[.allowAnimations] && !self.reduceMotion ? $0.animation : .linear(duration: 0) }) 98 | ) 99 | bind( 100 | .allowAnimations, 101 | property: \.animation, 102 | transformer: .init(to: { $0 && !self.reduceMotion ? Defaults[.animationSpeed].animation : .linear(duration: 0) }) 103 | ) 104 | bind( 105 | .allowAnimations, 106 | property: \.shouldAnimate, 107 | transformer: .init(to: { $0 && !self.reduceMotion }) 108 | ) 109 | bind(.allowAnimationsInLPM, property: \.allowAnimationsInLPM) 110 | 111 | NotificationCenter.default.publisher(for: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification) 112 | .debounce(for: .milliseconds(100), scheduler: RunLoop.main) 113 | .sink { _ in self.reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion } 114 | .store(in: &observers) 115 | 116 | if #available(macOS 12.0, *) { 117 | NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange) 118 | .debounce(for: .milliseconds(100), scheduler: RunLoop.main) 119 | .sink { _ in self.lowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled } 120 | .store(in: &observers) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Lowtech", 8 | platforms: [ 9 | .macOS(.v11), 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "Lowtech", 15 | targets: ["Lowtech"] 16 | ), 17 | .library( 18 | name: "LowtechAppStore", 19 | targets: ["LowtechAppStore"] 20 | ), 21 | .library( 22 | name: "LowtechIndie", 23 | targets: ["LowtechIndie"] 24 | ), 25 | .library( 26 | name: "LowtechPro", 27 | targets: ["LowtechPro"] 28 | ), 29 | ], 30 | dependencies: [ 31 | // Dependencies declare other packages that this package depends on. 32 | .package(url: "https://github.com/sindresorhus/Defaults", from: "7.0.0"), 33 | .package(url: "https://github.com/mxcl/Path.swift", from: "1.4.0"), 34 | .package(url: "https://github.com/apple/swift-atomics", from: "1.0.2"), 35 | .package(url: "https://github.com/alin23/RegexSwiftOld", from: "1.3.0"), 36 | .package(url: "https://github.com/alin23/FuzzyMatcher", branch: "main"), 37 | .package(url: "https://github.com/sindresorhus/LaunchAtLogin", branch: "main"), 38 | .package(url: "https://github.com/alin23/Magnet", from: "4.1.2"), 39 | .package(url: "https://github.com/Clipy/Sauce", from: "2.2.0"), 40 | .package(url: "https://github.com/twostraws/VisualEffects", from: "1.0.3"), 41 | .package(url: "https://github.com/eonil/FSEvents", from: "0.1.7"), 42 | .package(url: "https://github.com/yannickl/DynamicColor", from: "5.0.1"), 43 | .package(url: "https://github.com/diniska/swiftui-system-colors", from: "1.1.0"), 44 | .package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"), 45 | .package(url: "https://github.com/marcprux/MemoZ.git", from: "1.3.0"), 46 | .package(url: "https://github.com/alin23/AppReceiptValidator", from: "1.2.0"), 47 | .package(url: "https://github.com/Kitura/BlueECC", branch: "master"), 48 | 49 | .package(url: "https://github.com/alin23/PaddleSPM", branch: "main"), 50 | .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.2.0"), 51 | ], 52 | targets: [ 53 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 54 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 55 | .target( 56 | name: "Lowtech", 57 | dependencies: [ 58 | .product(name: "Atomics", package: "swift-atomics"), 59 | .product(name: "Path", package: "Path.swift"), 60 | .product(name: "Defaults", package: "Defaults"), 61 | .product(name: "Regex", package: "RegexSwiftOld"), 62 | .product(name: "LaunchAtLogin", package: "LaunchAtLogin"), 63 | .product(name: "Magnet", package: "Magnet"), 64 | .product(name: "Sauce", package: "Sauce"), 65 | .product(name: "VisualEffects", package: "VisualEffects"), 66 | .product(name: "EonilFSEvents", package: "FSEvents"), 67 | .product(name: "DynamicColor", package: "DynamicColor"), 68 | .product(name: "FuzzyMatcher", package: "FuzzyMatcher"), 69 | .product(name: "SystemColors", package: "swiftui-system-colors"), 70 | .product(name: "MemoZ", package: "MemoZ"), 71 | ] 72 | ), 73 | .target( 74 | name: "LowtechAppStore", 75 | dependencies: [ 76 | "Lowtech", 77 | .product(name: "SwiftDate", package: "SwiftDate"), 78 | .product(name: "AppReceiptValidator", package: "AppReceiptValidator"), 79 | .product(name: "CryptorECC", package: "BlueECC"), 80 | ], 81 | exclude: ["Numbers.swift.secret"] 82 | 83 | ), 84 | .target( 85 | name: "LowtechIndie", 86 | dependencies: [ 87 | "Lowtech", 88 | .product(name: "Sparkle", package: "Sparkle"), 89 | ] 90 | ), 91 | .target( 92 | name: "LowtechPro", 93 | dependencies: [ 94 | "LowtechIndie", 95 | .product(name: "Paddle", package: "PaddleSPM"), 96 | ] 97 | ), 98 | ] 99 | ) 100 | -------------------------------------------------------------------------------- /Sources/Lowtech/Colors.swift: -------------------------------------------------------------------------------- 1 | import DynamicColor 2 | import SwiftUI 3 | import SystemColors 4 | 5 | // MARK: - Colors 6 | 7 | public struct Colors { 8 | public init(_ colorScheme: SwiftUI.ColorScheme = .light, accent: Color) { 9 | self.accent = accent 10 | self.colorScheme = colorScheme 11 | bg = BG(colorScheme: colorScheme) 12 | fg = FG(colorScheme: colorScheme) 13 | } 14 | 15 | public struct FG { 16 | public var colorScheme: SwiftUI.ColorScheme 17 | 18 | public var isDark: Bool { colorScheme == .dark } 19 | public var isLight: Bool { colorScheme == .light } 20 | 21 | var gray: Color { isDark ? Colors.lightGray : Colors.darkGray } 22 | var primary: Color { isDark ? .white : .black } 23 | } 24 | 25 | public struct BG { 26 | public var colorScheme: SwiftUI.ColorScheme 27 | 28 | public var isDark: Bool { colorScheme == .dark } 29 | public var isLight: Bool { colorScheme == .light } 30 | 31 | var gray: Color { isDark ? Colors.darkGray : Colors.lightGray } 32 | var primary: Color { isDark ? .black : .white } 33 | } 34 | 35 | public static var light = Colors(.light, accent: Colors.lunarYellow) 36 | public static var dark = Colors(.dark, accent: Colors.peach) 37 | 38 | public static let darkGray = Color(hue: 0, saturation: 0.01, brightness: 0.32) 39 | public static let blackGray = Color(hue: 0.03, saturation: 0.12, brightness: 0.18) 40 | public static let lightGray = Color(hue: 0, saturation: 0.0, brightness: 0.92) 41 | 42 | public static let red = Color(hue: 0.98, saturation: 0.82, brightness: 1.00) 43 | public static let lightGold = Color(hue: 0.09, saturation: 0.28, brightness: 0.94) 44 | 45 | public static let blackTurqoise = Color(hex: 0x1D2E32) 46 | public static let burntSienna = Color(hex: 0xE48659) 47 | public static let scarlet = Color(NSColor(hue: 0.98, saturation: 0.82, brightness: 1.00, alpha: 1.00)) 48 | public static let saffron = Color(NSColor(hue: 0.11, saturation: 0.82, brightness: 1.00, alpha: 1.00)) 49 | 50 | public static let grayMauve = Color(hue: 252 / 360, saturation: 0.29, brightness: 0.43) 51 | public static let mauve = Color(hue: 252 / 360, saturation: 0.29, brightness: 0.23) 52 | public static let pinkMauve = Color(hue: 0.95, saturation: 0.76, brightness: 0.42) 53 | public static let blackMauve = Color( 54 | hue: 252 / 360, 55 | saturation: 0.08, 56 | brightness: 57 | 0.12 58 | ) 59 | public static let yellow = Color(hue: 39 / 360, saturation: 1.0, brightness: 0.64) 60 | public static let lunarYellow = Color(hue: 0.11, saturation: 0.47, brightness: 1.00) 61 | public static let sunYellow = Color(hue: 0.1, saturation: 0.57, brightness: 1.00) 62 | public static let peach = Color(hue: 0.08, saturation: 0.42, brightness: 1.00) 63 | public static let blue = Color(hue: 214 / 360, saturation: 0.7, brightness: 0.84) 64 | public static let green = Color(hue: 0.36, saturation: 0.80, brightness: 0.78) 65 | public static let lightGreen = Color(hue: 141 / 360, saturation: 0.50, brightness: 0.83) 66 | 67 | public static let xdr = Color(hue: 0.61, saturation: 0.26, brightness: 0.78) 68 | public static let subzero = Color(hue: 0.98, saturation: 0.56, brightness: 1.00) 69 | 70 | public var accent: Color 71 | public var colorScheme: SwiftUI.ColorScheme 72 | 73 | public var bg: BG 74 | public var fg: FG 75 | 76 | public var isDark: Bool { colorScheme == .dark } 77 | public var isLight: Bool { colorScheme == .light } 78 | public var inverted: Color { isDark ? .black : .white } 79 | public var highContrast: Color { isDark ? .white : .black } 80 | public var invertedGray: Color { isDark ? Colors.darkGray : Colors.lightGray } 81 | public var gray: Color { isDark ? Colors.lightGray : Colors.darkGray } 82 | } 83 | 84 | // MARK: - ColorsKey 85 | 86 | private struct ColorsKey: EnvironmentKey { 87 | public static let defaultValue = Colors.light 88 | } 89 | 90 | public extension EnvironmentValues { 91 | var colors: Colors { 92 | get { self[ColorsKey.self] } 93 | set { self[ColorsKey.self] = newValue } 94 | } 95 | } 96 | 97 | public extension View { 98 | func colors(_ colors: Colors) -> some View { 99 | environment(\.colors, colors) 100 | } 101 | } 102 | 103 | public extension Color { 104 | var isLight: Bool { 105 | let components = ns.toRGBAComponents() 106 | let brightness = ((components.r * 299.0) + (components.g * 587.0) + (components.b * 114.0)) / 1000.0 107 | 108 | return brightness >= 0.4 109 | } 110 | 111 | func textColor(colors: Colors) -> Color { 112 | switch colors.colorScheme { 113 | case .light: 114 | return memoz.isLight ? colors.highContrast : colors.inverted 115 | case .dark: 116 | return memoz.isLight ? colors.inverted : colors.highContrast 117 | @unknown default: 118 | return memoz.isLight ? colors.highContrast : colors.inverted 119 | } 120 | } 121 | } 122 | 123 | var initialColorScheme: ColorScheme! 124 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "AppReceiptValidator", 6 | "repositoryURL": "https://github.com/alin23/AppReceiptValidator", 7 | "state": { 8 | "branch": null, 9 | "revision": "e35794a03103839e024f71534e263310038c930d", 10 | "version": "1.2.0" 11 | } 12 | }, 13 | { 14 | "package": "ASN1Decoder", 15 | "repositoryURL": "https://github.com/IdeasOnCanvas/ASN1Decoder", 16 | "state": { 17 | "branch": null, 18 | "revision": "1fa1e9c68c27cbed56e2a997605ae2e808cf4d98", 19 | "version": "1.8.2" 20 | } 21 | }, 22 | { 23 | "package": "CryptorECC", 24 | "repositoryURL": "https://github.com/Kitura/BlueECC", 25 | "state": { 26 | "branch": "master", 27 | "revision": "1485268a54f8135435a825a855e733f026fa6cc8", 28 | "version": null 29 | } 30 | }, 31 | { 32 | "package": "Defaults", 33 | "repositoryURL": "https://github.com/sindresorhus/Defaults", 34 | "state": { 35 | "branch": null, 36 | "revision": "3efef5a28ebdbbe922d4a2049493733ed14475a6", 37 | "version": "7.3.1" 38 | } 39 | }, 40 | { 41 | "package": "DynamicColor", 42 | "repositoryURL": "https://github.com/yannickl/DynamicColor", 43 | "state": { 44 | "branch": null, 45 | "revision": "d9beca13ca85baa50ee29832d0db5129fba69630", 46 | "version": "5.0.1" 47 | } 48 | }, 49 | { 50 | "package": "EonilFSEvents", 51 | "repositoryURL": "https://github.com/eonil/FSEvents", 52 | "state": { 53 | "branch": null, 54 | "revision": "e6b7cdfa2754454e194a45ee6c2004d0f630fe88", 55 | "version": "0.1.7" 56 | } 57 | }, 58 | { 59 | "package": "FuzzyMatcher", 60 | "repositoryURL": "https://github.com/alin23/FuzzyMatcher", 61 | "state": { 62 | "branch": "main", 63 | "revision": "6c7628a46a566d64d6b7d79068c95bf80c8a6bd6", 64 | "version": null 65 | } 66 | }, 67 | { 68 | "package": "LaunchAtLogin", 69 | "repositoryURL": "https://github.com/sindresorhus/LaunchAtLogin", 70 | "state": { 71 | "branch": "main", 72 | "revision": "9a894d799269cb591037f9f9cb0961510d4dca81", 73 | "version": null 74 | } 75 | }, 76 | { 77 | "package": "Magnet", 78 | "repositoryURL": "https://github.com/alin23/Magnet", 79 | "state": { 80 | "branch": null, 81 | "revision": "45cadc3e5f95d33bddea532886370d09257af45d", 82 | "version": "4.1.2" 83 | } 84 | }, 85 | { 86 | "package": "MemoZ", 87 | "repositoryURL": "https://github.com/marcprux/MemoZ.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "473ee3559b4446fb81c7197fb01293373ddf6b4a", 91 | "version": "1.5.2" 92 | } 93 | }, 94 | { 95 | "package": "Paddle", 96 | "repositoryURL": "https://github.com/alin23/PaddleSPM", 97 | "state": { 98 | "branch": "main", 99 | "revision": "d5446f4274ed2f01701758f40c9253b93fc43dd9", 100 | "version": null 101 | } 102 | }, 103 | { 104 | "package": "Path.swift", 105 | "repositoryURL": "https://github.com/mxcl/Path.swift", 106 | "state": { 107 | "branch": null, 108 | "revision": "8e355c28e9393c42e58b18c54cace2c42c98a616", 109 | "version": "1.4.1" 110 | } 111 | }, 112 | { 113 | "package": "Regex", 114 | "repositoryURL": "https://github.com/alin23/RegexSwiftOld", 115 | "state": { 116 | "branch": null, 117 | "revision": "7f24e8ff7cbae75c333f6e080a422855d1a7d961", 118 | "version": "1.3.0" 119 | } 120 | }, 121 | { 122 | "package": "Sauce", 123 | "repositoryURL": "https://github.com/Clipy/Sauce", 124 | "state": { 125 | "branch": null, 126 | "revision": "2fcf7e43a242b183fdea3f2275ebec0d773b65f5", 127 | "version": "2.2.0" 128 | } 129 | }, 130 | { 131 | "package": "Sparkle", 132 | "repositoryURL": "https://github.com/sparkle-project/Sparkle", 133 | "state": { 134 | "branch": null, 135 | "revision": "0ef1ee0220239b3776f433314515fd849025673f", 136 | "version": "2.6.4" 137 | } 138 | }, 139 | { 140 | "package": "swift-atomics", 141 | "repositoryURL": "https://github.com/apple/swift-atomics", 142 | "state": { 143 | "branch": null, 144 | "revision": "cd142fd2f64be2100422d658e7411e39489da985", 145 | "version": "1.2.0" 146 | } 147 | }, 148 | { 149 | "package": "swift-crypto", 150 | "repositoryURL": "https://github.com/apple/swift-crypto", 151 | "state": { 152 | "branch": null, 153 | "revision": "60f13f60c4d093691934dc6cfdf5f508ada1f894", 154 | "version": "2.6.0" 155 | } 156 | }, 157 | { 158 | "package": "SwiftDate", 159 | "repositoryURL": "https://github.com/malcommac/SwiftDate", 160 | "state": { 161 | "branch": null, 162 | "revision": "6190d0cefff3013e77ed567e6b074f324e5c5bf5", 163 | "version": "6.3.1" 164 | } 165 | }, 166 | { 167 | "package": "SystemColors", 168 | "repositoryURL": "https://github.com/diniska/swiftui-system-colors", 169 | "state": { 170 | "branch": null, 171 | "revision": "5127afab23337699a714e036588f02d9481ae612", 172 | "version": "1.2.0" 173 | } 174 | }, 175 | { 176 | "package": "VisualEffects", 177 | "repositoryURL": "https://github.com/twostraws/VisualEffects", 178 | "state": { 179 | "branch": null, 180 | "revision": "8ff7f90f9650d9db5774275d0b1f07ef642ecf4d", 181 | "version": "1.0.3" 182 | } 183 | } 184 | ] 185 | }, 186 | "version": 1 187 | } 188 | -------------------------------------------------------------------------------- /Sources/Lowtech/LowtechAppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Combine 3 | import Defaults 4 | import Foundation 5 | import SwiftUI 6 | 7 | let kAppleInterfaceThemeChangedNotification = "AppleInterfaceThemeChangedNotification" 8 | 9 | public extension Notification.Name { 10 | static let mainScreenChanged = Notification.Name("MainScreenChanged") 11 | } 12 | 13 | // MARK: - LowtechAppDelegate 14 | 15 | open class LowtechAppDelegate: NSObject, NSApplicationDelegate, ObservableObject { 16 | @Published open var showPopoverOnSpecialKey = true 17 | 18 | open var initialized = false 19 | open var env = EnvState() 20 | 21 | open func isTrialMode() -> Bool { 22 | false 23 | } 24 | 25 | @MainActor 26 | open func applicationDidBecomeActive(_ notification: Notification) { 27 | guard didBecomeActiveAtLeastOnce else { 28 | didBecomeActiveAtLeastOnce = true 29 | return 30 | } 31 | 32 | if Defaults[.hideMenubarIcon] { 33 | statusBar?.showPopoverIfNotVisible() 34 | } 35 | } 36 | 37 | @MainActor 38 | open func onAppearanceChanged(_ appearance: NSAppearance) {} 39 | 40 | @MainActor 41 | open func initObservers() { 42 | DistributedNotificationCenter.default() 43 | .publisher(for: NSNotification.Name(rawValue: kAppleInterfaceThemeChangedNotification), object: nil) 44 | .debounce(for: .milliseconds(50), scheduler: RunLoop.main) 45 | .sink { notification in 46 | NSAppearance.current = NSApp.effectiveAppearance 47 | self.onAppearanceChanged(NSAppearance.current) 48 | }.store(in: &observers) 49 | 50 | NotificationCenter.default 51 | .publisher(for: NSApplication.didChangeScreenParametersNotification, object: nil) 52 | .eraseToAnyPublisher().map { $0 as Any? } 53 | .merge(with: NSWorkspace.shared.publisher(for: \.frontmostApplication).eraseToAnyPublisher().map { $0 as Any? }) 54 | .merge( 55 | with: 56 | NSWorkspace.shared.notificationCenter 57 | .publisher(for: NSWorkspace.activeSpaceDidChangeNotification, object: nil) 58 | .eraseToAnyPublisher().map { $0 as Any? } 59 | ) 60 | .sink { notification in 61 | NotificationCenter.default.post(name: .mainScreenChanged, object: nil) 62 | }.store(in: &observers) 63 | } 64 | 65 | @MainActor open func applicationDidFinishLaunching(_ notification: Notification) { 66 | #if DEBUG 67 | print(notification) 68 | #endif 69 | 70 | LowtechAppDelegate.instance = self 71 | Defaults[.launchCount] += 1 72 | 73 | initMenubar() 74 | initObservers() 75 | KM.onSpecialHotkey = { [self] in 76 | guard showPopoverOnSpecialKey else { 77 | return 78 | } 79 | 80 | statusBar?.togglePopover(sender: self) 81 | } 82 | KM.initHotkeys() 83 | KM.initFlagsListener() 84 | 85 | if Defaults[.launchCount] == 1, showPopoverOnFirstLaunch { 86 | mainAsyncAfter(ms: 1000) { 87 | guard let s = self.statusBar, s.window == nil || !s.window!.isVisible else { return } 88 | s.showPopover(self) 89 | } 90 | } 91 | initialized = true 92 | KM.initialized = true 93 | } 94 | 95 | @MainActor 96 | open func onPopoverNotAllowed() {} 97 | 98 | public private(set) static var instance: LowtechAppDelegate! 99 | 100 | public lazy var trialMode = isTrialMode() 101 | 102 | public var showPopoverOnFirstLaunch = true 103 | public var statusBar: StatusBarController? 104 | public var application = NSApplication.shared 105 | 106 | public var observers: Set = [] 107 | 108 | public var contentView: AnyView? 109 | public var accentColor: Color? 110 | 111 | public var appStoreURL: URL? 112 | 113 | public var notificationPopover: PanelWindow! { 114 | didSet { 115 | oldValue?.forceClose() 116 | } 117 | } 118 | 119 | public var notificationCloser: DispatchWorkItem? { 120 | didSet { 121 | guard let oldCloser = oldValue else { 122 | return 123 | } 124 | oldCloser.cancel() 125 | } 126 | } 127 | 128 | @MainActor 129 | public func hidePopover() { 130 | statusBar?.hidePopover(self) 131 | } 132 | 133 | @MainActor 134 | public func showNotification( 135 | title: String, 136 | lines: [String], 137 | yesButtonText: String? = nil, 138 | noButtonText: String? = nil, 139 | closeAfter closeMilliseconds: Int = 4000, 140 | menubarIconHidden: Bool? = nil, 141 | action: ((Bool) -> Void)? = nil 142 | ) { 143 | guard let screen = NSScreen.main else { return } 144 | let view = NotificationView( 145 | notificationLines: (title.isEmpty ? [] : ["# \(title)"]) + lines, 146 | yesButtonText: yesButtonText, noButtonText: noButtonText, buttonAction: action 147 | ).any 148 | notificationPopover = PanelWindow(swiftuiView: view) 149 | if notificationPopover.frame.height == 0 { 150 | notificationPopover.show(at: NSPoint( 151 | x: screen.visibleFrame.maxX, 152 | y: screen.visibleFrame.maxY - 100 153 | ), activate: false) 154 | } 155 | mainAsyncAfter(ms: 1) { 156 | self.notificationPopover.show(at: NSPoint( 157 | x: screen.visibleFrame.maxX - (self.notificationPopover?.contentView!.frame.width ?? 400), 158 | y: screen.visibleFrame.maxY - (self.notificationPopover?.contentView!.frame.height ?? 100) 159 | ), activate: false) 160 | } 161 | notificationCloser = mainAsyncAfter(ms: closeMilliseconds) { 162 | guard let notif = self.notificationPopover, notif.isVisible else { return } 163 | notif.forceClose() 164 | } 165 | } 166 | 167 | @MainActor 168 | public func initMenubar() { 169 | guard let contentView else { 170 | return 171 | } 172 | 173 | let color = accentColor ?? Colors.yellow 174 | statusBar = StatusBarController( 175 | LowtechView(accentColor: color) { contentView }.any 176 | ) 177 | } 178 | 179 | private var didBecomeActiveAtLeastOnce = false 180 | 181 | } 182 | -------------------------------------------------------------------------------- /Sources/Lowtech/StatusBarController.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Combine 3 | import Defaults 4 | import SwiftUI 5 | 6 | public extension NSNotification.Name { 7 | static let closePopover: NSNotification.Name = .init("closePopover") 8 | static let openPopover: NSNotification.Name = .init("openPopover") 9 | } 10 | 11 | // MARK: - StatusBarDelegate 12 | 13 | class StatusBarDelegate: NSObject, NSWindowDelegate { 14 | convenience init(statusBarController: StatusBarController) { 15 | self.init() 16 | self.statusBarController = statusBarController 17 | } 18 | 19 | var statusBarController: StatusBarController! 20 | 21 | @MainActor 22 | func reposition() { 23 | guard let window = statusBarController.window, window.isVisible, let position = statusBarController.position else { return } 24 | 25 | window.show(at: position, animate: true) 26 | } 27 | 28 | func windowDidMove(_ notification: Notification) { 29 | mainActor { self.reposition() } 30 | } 31 | } 32 | 33 | // MARK: - StatusBarController 34 | 35 | @MainActor 36 | open class StatusBarController: NSObject, NSWindowDelegate, ObservableObject { 37 | public init(_ view: @autoclosure @escaping () -> AnyView, image: String = "MenubarIcon") { 38 | self.view = view 39 | 40 | statusBar = NSStatusBar.system 41 | statusItem = statusBar.statusItem(withLength: NSStatusItem.squareLength) 42 | 43 | super.init() 44 | delegate = StatusBarDelegate(statusBarController: self) 45 | statusItem.button?.window?.delegate = delegate 46 | 47 | NotificationCenter.default.publisher(for: .closePopover) 48 | .debounce(for: .milliseconds(10), scheduler: RunLoop.main) 49 | .sink { _ in self.hidePopover(LowtechAppDelegate.instance) } 50 | .store(in: &observers) 51 | NotificationCenter.default.publisher(for: .openPopover) 52 | .debounce(for: .milliseconds(10), scheduler: RunLoop.main) 53 | .sink { _ in self.showPopover(LowtechAppDelegate.instance) } 54 | .store(in: &observers) 55 | 56 | if let statusBarButton = statusItem.button { 57 | statusBarButton.image = NSImage(named: image) 58 | statusBarButton.image?.size = NSSize(width: 18.0, height: 18.0) 59 | statusBarButton.image?.isTemplate = true 60 | 61 | statusBarButton.action = #selector(statusItemClick(sender:)) 62 | statusBarButton.target = self 63 | } 64 | 65 | if Defaults[.hideMenubarIcon], let statusBarButton = statusItem.button { 66 | statusBarButton.image = nil 67 | statusItem.isVisible = false 68 | } 69 | 70 | Defaults.publisher(.hideMenubarIcon).removeDuplicates().filter { $0.oldValue != $0.newValue }.sink { [self] hidden in 71 | let showingMenubarIcon = !hidden.newValue 72 | let hidingMenubarIcon = hidden.newValue 73 | 74 | hidePopover(self) 75 | statusItem.isVisible = showingMenubarIcon 76 | statusItem.button?.image = hidingMenubarIcon ? nil : NSImage(named: image) 77 | 78 | guard popoverShownAtLeastOnce else { return } 79 | mainAsyncAfter(ms: 10) { 80 | self.showPopover(self) 81 | } 82 | }.store(in: &observers) 83 | 84 | eventMonitor = GlobalEventMonitor(mask: [.leftMouseDown, .rightMouseDown], handler: mouseEventHandler) 85 | dragEventMonitorLocal = LocalEventMonitor(mask: [.leftMouseDown, .leftMouseUp, .rightMouseUp, .otherMouseUp]) { self.dragHandler($0) } 86 | dragEventMonitorLocal.start() 87 | dragEventMonitorGlobal = GlobalEventMonitor(mask: [.leftMouseDown, .leftMouseUp, .rightMouseUp, .otherMouseUp]) { ev in 88 | guard self.draggingWindow else { 89 | return 90 | } 91 | 92 | self.draggingWindow = false 93 | if self.changedWindowScreen { 94 | self.changedWindowScreen = false 95 | NotificationCenter.default.post(name: .mainScreenChanged, object: nil) 96 | } 97 | } 98 | dragEventMonitorGlobal.start() 99 | 100 | NSApp.publisher(for: \.mainMenu).sink { _ in self.fixMenu() } 101 | .store(in: &observers) 102 | } 103 | 104 | open func windowWillClose(_: Notification) { 105 | debug("windowWillClose") 106 | if !Defaults[.popoverClosed] { 107 | Defaults[.popoverClosed] = true 108 | } 109 | } 110 | 111 | public var view: () -> AnyView 112 | public var screenObserver: Cancellable? 113 | public var observers: Set = [] 114 | public var statusItem: NSStatusItem 115 | @Atomic public var popoverShownAtLeastOnce = false 116 | @Atomic public var shouldLeavePopoverOpen = false 117 | @Atomic public var shouldDestroyWindowOnClose = true 118 | 119 | @Published public var storedPosition: CGPoint = .zero 120 | public var onWindowCreation: ((PanelWindow) -> Void)? 121 | public var centerOnScreen = false 122 | public var screenCorner: ScreenCorner? 123 | public var margin: CGFloat? 124 | 125 | @Atomic public var changedWindowScreen = false 126 | @Atomic public var draggingWindow = false 127 | 128 | public var allowPopover = true 129 | 130 | public var window: PanelWindow? { 131 | didSet { 132 | window?.delegate = self 133 | 134 | oldValue?.forceClose() 135 | 136 | if let window { 137 | screenObserver = NotificationCenter.default.publisher(for: NSWindow.didChangeScreenNotification, object: window) 138 | .sink { _ in 139 | guard !self.draggingWindow else { 140 | self.changedWindowScreen = true 141 | return 142 | } 143 | NotificationCenter.default.post(name: .mainScreenChanged, object: nil) 144 | } 145 | } else { 146 | screenObserver = nil 147 | } 148 | guard let onWindowCreation, let window else { return } 149 | onWindowCreation(window) 150 | } 151 | } 152 | 153 | public var position: CGPoint? { 154 | guard !centerOnScreen, let button = statusItem.button, let screen = NSScreen.main, 155 | let menuBarIconPosition = button.window?.convertPoint(toScreen: button.frame.origin), 156 | let window, var viewSize = window.contentView?.frame.size 157 | else { return nil } 158 | 159 | viewSize.width = max(viewSize.width, 480) 160 | viewSize.height = max(viewSize.height, 680) 161 | var middle = CGPoint( 162 | x: menuBarIconPosition.x - viewSize.width / 2, 163 | y: screen.visibleFrame.maxY - (window.frame.height + 1) 164 | ) 165 | 166 | if middle.x + window.frame.width > screen.visibleFrame.maxX { 167 | middle = CGPoint(x: screen.visibleFrame.maxX - window.frame.width, y: middle.y) 168 | } else if middle.x < screen.visibleFrame.minX { 169 | middle = CGPoint(x: screen.visibleFrame.minX, y: middle.y) 170 | } 171 | 172 | if storedPosition != middle { 173 | storedPosition = middle 174 | } 175 | return middle 176 | } 177 | 178 | @objc public func statusItemClick(sender: AnyObject) { 179 | draggingWindow = false 180 | togglePopover(sender: sender) 181 | draggingWindow = false 182 | } 183 | 184 | @objc public func togglePopover(sender: AnyObject) { 185 | if let window, window.isVisible { 186 | hidePopover(sender) 187 | } else { 188 | showPopover(sender) 189 | } 190 | } 191 | 192 | public func showPopoverIfNotVisible() { 193 | guard window == nil || !window!.isVisible else { return } 194 | showPopover(self) 195 | } 196 | 197 | public func fixMenu() { 198 | let menu = NSMenu(title: "Edit") 199 | 200 | menu.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") 201 | menu.addItem(withTitle: "Close Window", action: #selector(StatusBarController.hidePopover(_:)), keyEquivalent: "w") 202 | menu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") 203 | menu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") 204 | menu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") 205 | menu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") 206 | menu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") 207 | menu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") 208 | 209 | let editMenuItem = NSMenuItem() 210 | editMenuItem.title = "Edit" 211 | editMenuItem.submenu = menu 212 | if NSApp.mainMenu == nil { 213 | NSApp.mainMenu = NSMenu() 214 | } 215 | NSApp.mainMenu?.items = [editMenuItem] 216 | } 217 | 218 | public func showPopover(_: AnyObject) { 219 | LowtechAppDelegate.instance.env.menuHideTask = nil 220 | 221 | Defaults[.popoverClosed] = false 222 | popoverShownAtLeastOnce = true 223 | 224 | if window == nil { 225 | window = PanelWindow(swiftuiView: view()) 226 | } 227 | guard statusItem.isVisible else { 228 | if allowPopover { 229 | window!.show(at: centerOnScreen ? nil : .mouseLocation(centeredOn: window), corner: screenCorner, margin: margin) 230 | } else { 231 | LowtechAppDelegate.instance.onPopoverNotAllowed() 232 | } 233 | return 234 | } 235 | 236 | if allowPopover { 237 | window!.show(at: position, corner: screenCorner, margin: margin) 238 | } else { 239 | LowtechAppDelegate.instance.onPopoverNotAllowed() 240 | } 241 | eventMonitor?.start() 242 | } 243 | 244 | @objc public func hidePopover(_: AnyObject) { 245 | LowtechAppDelegate.instance.env.menuHideTask = nil 246 | 247 | guard let window else { return } 248 | window.close() 249 | eventMonitor?.stop() 250 | NSApp.deactivate() 251 | 252 | guard shouldDestroyWindowOnClose else { return } 253 | LowtechAppDelegate.instance.env.menuHideTask = mainAsyncAfter(ms: 2000) { 254 | self.window?.contentView = nil 255 | self.window?.forceClose() 256 | self.window = nil 257 | } 258 | } 259 | 260 | var dragEventMonitorLocal: LocalEventMonitor! 261 | var dragEventMonitorGlobal: GlobalEventMonitor! 262 | 263 | var delegate: StatusBarDelegate? 264 | 265 | func dragHandler(_ ev: NSEvent) -> NSEvent? { 266 | switch ev.type { 267 | case .leftMouseDown: 268 | draggingWindow = true 269 | case .leftMouseUp, .rightMouseUp, .otherMouseUp: 270 | draggingWindow = false 271 | if changedWindowScreen { 272 | changedWindowScreen = false 273 | NotificationCenter.default.post(name: .mainScreenChanged, object: nil) 274 | } 275 | default: 276 | break 277 | } 278 | return ev 279 | } 280 | 281 | func mouseEventHandler(_ event: NSEvent?) { 282 | guard let window, event?.window == nil else { return } 283 | if window.isVisible, statusItem.isVisible, !shouldLeavePopoverOpen { 284 | hidePopover(LowtechAppDelegate.instance) 285 | } 286 | } 287 | 288 | private var statusBar: NSStatusBar 289 | private var eventMonitor: GlobalEventMonitor? 290 | } 291 | -------------------------------------------------------------------------------- /Sources/Lowtech/Hashids.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Hashids.swift 3 | // http://hashids.org 4 | // 5 | // Author https://github.com/malczak 6 | // Licensed under the MIT license. 7 | // 8 | 9 | import Foundation 10 | 11 | // MARK: - HashidsOptions 12 | 13 | public enum HashidsOptions { 14 | static let VERSION = "1.1.0" 15 | static var MIN_ALPHABET_LENGTH = 16 16 | static var SEP_DIV = 3.5 17 | static var GUARD_DIV: Double = 12 18 | static var ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" 19 | static var SEPARATORS = "cfhistuCFHISTU" 20 | } 21 | 22 | // MARK: - HashidsGenerator 23 | 24 | public protocol HashidsGenerator { 25 | associatedtype Char 26 | 27 | func encode(_ value: Int64...) -> String? 28 | func encode(_ values: [Int64]) -> String? 29 | func encode(_ value: Int...) -> String? 30 | func encode(_ values: [Int]) -> String? 31 | func decode(_ value: String!) -> [Int] 32 | func decode(_ value: [Char]) -> [Int] 33 | func decode64(_ value: String) -> [Int64] 34 | func decode64(_ value: [Char]) -> [Int64] 35 | } 36 | 37 | // MARK: Hashids class 38 | 39 | public typealias Hashids = Hashids_ 40 | 41 | // MARK: - Hashids_ 42 | 43 | open class Hashids_: HashidsGenerator where T: UnsignedInteger { 44 | public init(salt: String!, minHashLength: UInt = 0, alphabet: String? = nil) { 45 | let _alphabet = (alphabet != nil) ? alphabet! : HashidsOptions.ALPHABET 46 | let _seps = HashidsOptions.SEPARATORS 47 | 48 | self.minHashLength = minHashLength 49 | guards = [Char]() 50 | self.salt = salt.unicodeScalars.map { 51 | numericCast($0.value) 52 | } 53 | seps = _seps.unicodeScalars.map { 54 | numericCast($0.value) 55 | } 56 | self.alphabet = unique(_alphabet.unicodeScalars.map { 57 | numericCast($0.value) 58 | }) 59 | 60 | seps = intersection(seps, self.alphabet) 61 | self.alphabet = difference(self.alphabet, seps) 62 | shuffle(&seps, self.salt) 63 | 64 | let sepsLength = seps.count 65 | let alphabetLength = self.alphabet.count 66 | 67 | if (sepsLength == 0) || (Double(alphabetLength) / Double(sepsLength) > HashidsOptions.SEP_DIV) { 68 | var newSepsLength = Int(ceil(Double(alphabetLength) / HashidsOptions.SEP_DIV)) 69 | 70 | if newSepsLength == 1 { 71 | newSepsLength += 1 72 | } 73 | 74 | if newSepsLength > sepsLength { 75 | let diff = self.alphabet.startIndex.advanced(by: newSepsLength - sepsLength) 76 | let range = 0 ..< diff 77 | seps += self.alphabet[range] 78 | self.alphabet.removeSubrange(range) 79 | } else { 80 | let pos = seps.startIndex.advanced(by: newSepsLength) 81 | seps.removeSubrange(pos + 1 ..< seps.count) 82 | } 83 | } 84 | 85 | shuffle(&self.alphabet, self.salt) 86 | 87 | let guard_i = Int(ceil(Double(alphabetLength) / HashidsOptions.GUARD_DIV)) 88 | if alphabetLength < 3 { 89 | let seps_guard = seps.startIndex.advanced(by: guard_i) 90 | let range = 0 ..< seps_guard 91 | guards += seps[range] 92 | seps.removeSubrange(range) 93 | } else { 94 | let alphabet_guard = self.alphabet.startIndex.advanced(by: guard_i) 95 | let range = 0 ..< alphabet_guard 96 | guards += self.alphabet[range] 97 | self.alphabet.removeSubrange(range) 98 | } 99 | } 100 | 101 | // MARK: public api 102 | 103 | open func encode(_ value: Int64...) -> String? { 104 | encode(value) 105 | } 106 | 107 | open func encode(_ values: [Int64]) -> String? { 108 | encode(values.map { Int($0) }) 109 | } 110 | 111 | open func encode(_ value: Int...) -> String? { 112 | encode(value) 113 | } 114 | 115 | open func encode(_ values: [Int]) -> String? { 116 | let ret = _encode(values) 117 | return ret.reduce(String()) { so, i in 118 | var so = so 119 | let scalar: UInt32 = numericCast(i) 120 | if let uniscalar = UnicodeScalar(scalar) { 121 | so.append(String(describing: uniscalar)) 122 | } 123 | return so 124 | } 125 | } 126 | 127 | open func decode(_ value: String!) -> [Int] { 128 | let trimmed = value.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) 129 | let hash: [Char] = trimmed.unicodeScalars.map { 130 | numericCast($0.value) 131 | } 132 | return decode(hash) 133 | } 134 | 135 | open func decode(_ value: [Char]) -> [Int] { 136 | _decode(value) 137 | } 138 | 139 | open func decode64(_ value: String) -> [Int64] { 140 | decode(value).map { Int64($0) } 141 | } 142 | 143 | open func decode64(_ value: [Char]) -> [Int64] { 144 | decode(value).map { Int64($0) } 145 | } 146 | 147 | public typealias Char = T 148 | 149 | fileprivate var minHashLength: UInt 150 | fileprivate var alphabet: [Char] 151 | fileprivate var seps: [Char] 152 | fileprivate var salt: [Char] 153 | fileprivate var guards: [Char] 154 | 155 | // MARK: private funcitons 156 | 157 | fileprivate func _encode(_ numbers: [Int]) -> [Char] { 158 | var alphabet = alphabet 159 | var numbers_hash_int = 0 160 | 161 | for (index, value) in numbers.enumerated() { 162 | numbers_hash_int += (value % (index + 100)) 163 | } 164 | 165 | let lottery = alphabet[numbers_hash_int % alphabet.count] 166 | var hash = [lottery] 167 | 168 | var lsalt = [Char]() 169 | let (lsaltARange, lsaltRange) = _saltify(&lsalt, lottery, alphabet) 170 | 171 | for (index, value) in numbers.enumerated() { 172 | shuffle(&alphabet, lsalt, lsaltRange) 173 | let lastIndex = hash.endIndex 174 | _hash(&hash, value, alphabet) 175 | 176 | if index + 1 < numbers.count { 177 | let number = value % (numericCast(hash[lastIndex]) + index) 178 | let seps_index = number % seps.count 179 | hash.append(seps[seps_index]) 180 | } 181 | 182 | lsalt.replaceSubrange(lsaltARange, with: alphabet) 183 | } 184 | 185 | let minLength: Int = numericCast(minHashLength) 186 | 187 | if hash.count < minLength { 188 | let guard_index = (numbers_hash_int + numericCast(hash[0])) % guards.count 189 | let guard_t = guards[guard_index] 190 | hash.insert(guard_t, at: 0) 191 | 192 | if hash.count < minLength { 193 | let guard_index = (numbers_hash_int + numericCast(hash[2])) % guards.count 194 | let guard_t = guards[guard_index] 195 | hash.append(guard_t) 196 | } 197 | } 198 | 199 | let half_length = alphabet.count >> 1 200 | while hash.count < minLength { 201 | shuffle(&alphabet, alphabet) 202 | let lrange = 0 ..< half_length 203 | let rrange = half_length ..< (alphabet.count) 204 | let alphabet_right = alphabet[rrange] 205 | let alphabet_left = alphabet[lrange] 206 | hash = [Char](alphabet_right) + hash + [Char](alphabet_left) 207 | 208 | let excess = hash.count - minLength 209 | if excess > 0 { 210 | let start = excess >> 1 211 | hash = [Char](hash[start ..< (start + minLength)]) 212 | } 213 | } 214 | 215 | return hash 216 | } 217 | 218 | fileprivate func _decode(_ hash: [Char]) -> [Int] { 219 | var ret = [Int]() 220 | 221 | var alphabet = alphabet 222 | 223 | let hashes = hash.split(maxSplits: hash.count, omittingEmptySubsequences: false) { 224 | contains(self.guards, $0) 225 | } 226 | let hashesCount = hashes.count, i = ((hashesCount == 2) || (hashesCount == 3)) ? 1 : 0 227 | let hash = hashes[i] 228 | let hashStartIndex = hash.startIndex 229 | 230 | if hash.count > 0 { 231 | let lottery = hash[hashStartIndex] 232 | let valuesHashes = hash[(hashStartIndex + 1) ..< (hashStartIndex + hash.count)] 233 | 234 | let valueHashes = valuesHashes.split(maxSplits: valuesHashes.count, omittingEmptySubsequences: false) { 235 | contains(self.seps, $0) 236 | } 237 | var lsalt = [Char]() 238 | let (lsaltARange, lsaltRange) = _saltify(&lsalt, lottery, alphabet) 239 | 240 | for subHash in valueHashes { 241 | shuffle(&alphabet, lsalt, lsaltRange) 242 | ret.append(_unhash(subHash, alphabet)) 243 | lsalt.replaceSubrange(lsaltARange, with: alphabet) 244 | } 245 | } 246 | 247 | return ret 248 | } 249 | 250 | fileprivate func _hash(_ hash: inout [Char], _ number: Int, _ alphabet: [Char]) { 251 | var number = number 252 | let length = alphabet.count, index = hash.count 253 | repeat { 254 | hash.insert(alphabet[number % length], at: index) 255 | number = number / length 256 | } while number != 0 257 | } 258 | 259 | fileprivate func _unhash(_ hash: U, _ alphabet: [Char]) -> Int where U.Index == Int, U.Iterator.Element == Char { 260 | var value: Double = 0 261 | 262 | var hashLength: Int = numericCast(hash.count) 263 | if hashLength > 0 { 264 | let alphabetLength = alphabet.count 265 | value = hash.reduce(0) { 266 | value, token in 267 | var tokenValue = 0.0 268 | if let token_index = alphabet.firstIndex(of: token as Char) { 269 | hashLength = hashLength - 1 270 | let mul = pow(Double(alphabetLength), Double(hashLength)) 271 | tokenValue = Double(token_index) * mul 272 | } 273 | return value + tokenValue 274 | } 275 | } 276 | 277 | return Int(trunc(value)) 278 | } 279 | 280 | fileprivate func _saltify(_ salt: inout [Char], _ lottery: Char, _ alphabet: [Char]) -> (Range, Range) { 281 | salt.append(lottery) 282 | salt = salt + self.salt 283 | salt = salt + alphabet 284 | let lsaltARange = (self.salt.count + 1) ..< salt.count 285 | let lsaltRange = 0 ..< alphabet.count 286 | return (lsaltARange, lsaltRange) 287 | } 288 | } 289 | 290 | // MARK: Internal functions 291 | 292 | func contains(_ a: T, _ e: T.Iterator.Element) -> Bool where T.Iterator.Element: Equatable { 293 | a.firstIndex(of: e) != nil 294 | } 295 | 296 | func transform(_ a: T, _ b: T, _ cmpr: (inout [T.Iterator.Element], T, T, T.Iterator.Element) -> Void) -> [T.Iterator.Element] where T.Iterator.Element: Equatable { 297 | typealias U = T.Iterator.Element 298 | var c = [U]() 299 | for i in a { 300 | cmpr(&c, a, b, i) 301 | } 302 | return c 303 | } 304 | 305 | func unique(_ a: T) -> [T.Iterator.Element] where T.Iterator.Element: Equatable { 306 | transform(a, a) { 307 | c, _, _, e in 308 | if !contains(c, e) { 309 | c.append(e) 310 | } 311 | } 312 | } 313 | 314 | func intersection(_ a: T, _ b: T) -> [T.Iterator.Element] where T.Iterator.Element: Equatable { 315 | transform(a, b) { 316 | c, _, b, e in 317 | if contains(b, e) { 318 | c.append(e) 319 | } 320 | } 321 | } 322 | 323 | func difference(_ a: T, _ b: T) -> [T.Iterator.Element] where T.Iterator.Element: Equatable { 324 | transform(a, b) { 325 | c, _, b, e in 326 | if !contains(b, e) { 327 | c.append(e) 328 | } 329 | } 330 | } 331 | 332 | func shuffle(_ source: inout T, _ salt: U) where T.Index == Int, T.Iterator.Element: UnsignedInteger, T.Iterator.Element == U.Iterator.Element, T.Index == U.Index { 333 | let saltCount: Int = numericCast(salt.count) 334 | shuffle(&source, salt, 0 ..< saltCount) 335 | } 336 | 337 | func shuffle(_ source: inout T, _ salt: U, _ saltRange: Range) where T.Index == Int, T.Iterator.Element: UnsignedInteger, T.Iterator.Element == U.Iterator.Element, T.Index == U.Index { 338 | let sidx0 = saltRange.lowerBound, scnt = (saltRange.upperBound - saltRange.lowerBound) 339 | guard scnt != 0 else { return } 340 | 341 | var sidx: Int = numericCast(source.count) - 1, v = 0, _p = 0 342 | while sidx > 0 { 343 | v = v % scnt 344 | let _i: Int = numericCast(salt[sidx0 + v]) 345 | _p += _i 346 | let _j: Int = (_i + v + _p) % sidx 347 | let tmp = source[sidx] 348 | source[sidx] = source[_j] 349 | source[_j] = tmp 350 | v += 1 351 | sidx = sidx - 1 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /Sources/LowtechPro/Pro.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Defaults 3 | import Lowtech 4 | import LowtechIndie 5 | import Paddle 6 | import SwiftDate 7 | 8 | // MARK: - LowtechProAppDelegate 9 | 10 | open class LowtechProAppDelegate: LowtechIndieAppDelegate, PADProductDelegate, PaddleDelegate { 11 | public static var showNextPaddleError = true 12 | 13 | public var paddleVendorID = "" 14 | public var paddleAPIKey = "" 15 | public var paddleProductID = "" 16 | public var productName = "" 17 | public var vendorName = "" 18 | public var price: NSNumber = 0 19 | public var currency = "USD" 20 | public var trialDays: NSNumber = 7 21 | public var trialType: PADProductTrialType = .timeLimited 22 | public var trialText = "" 23 | public var image = "" 24 | 25 | public lazy var pro = LowtechPro( 26 | paddleVendorID: paddleVendorID, 27 | paddleAPIKey: paddleAPIKey, 28 | paddleProductID: paddleProductID, 29 | productName: productName, 30 | vendorName: vendorName, 31 | price: price, 32 | currency: currency, 33 | trialDays: trialDays, 34 | trialType: trialType, 35 | trialText: trialText, 36 | image: image, 37 | productDelegate: self, 38 | paddleDelegate: self 39 | ) 40 | 41 | public func productPurchased(_ checkoutData: PADCheckoutData) { 42 | Defaults[.paddleConsent] = checkoutData.orderData?.hasMarketingConsent ?? false 43 | } 44 | 45 | public func productActivated() { 46 | pro.enablePro() 47 | } 48 | 49 | public func productDeactivated() { 50 | pro.disablePro() 51 | } 52 | 53 | public func canAutoActivate(_ product: PADProduct) -> Bool { 54 | guard let email = product.activationEmail, let code = product.licenseCode else { 55 | return false 56 | } 57 | product.activateEmail(email, license: code) 58 | return true 59 | } 60 | 61 | #if DEBUG 62 | @objc public func resetTrial() { 63 | guard let product else { 64 | return 65 | } 66 | product.resetTrial() 67 | pro.verifyLicense() 68 | } 69 | 70 | @objc public func expireTrial() { 71 | guard let product else { 72 | return 73 | } 74 | product.expireTrial() 75 | pro.verifyLicense() 76 | } 77 | #endif 78 | 79 | @IBAction public func activateLicense(_: Any) { 80 | pro.showLicenseActivation() 81 | if let statusBar, let w = statusBar.window, w.isVisible { 82 | w.makeKeyAndOrderFront(self) 83 | } 84 | } 85 | 86 | @IBAction public func recoverLicense(_: Any) { 87 | guard let paddle, let product else { 88 | return 89 | } 90 | paddle.showLicenseRecovery(for: product) { _, error in 91 | if let error { 92 | log.error("Error on recovering license from Paddle: \(error)") 93 | } 94 | } 95 | } 96 | 97 | @MainActor 98 | public func willShowPaddle(_: PADUIType, product _: PADProduct) -> PADDisplayConfiguration? { 99 | statusBar?.showPopoverIfNotVisible() 100 | 101 | if let window = statusBar?.window, window.isVisible { 102 | window.makeKeyAndOrderFront(nil) 103 | return PADDisplayConfiguration(.sheet, hideNavigationButtons: false, parentWindow: window) 104 | } 105 | 106 | return PADDisplayConfiguration(.window, hideNavigationButtons: false, parentWindow: nil) 107 | } 108 | 109 | @MainActor 110 | public func willShowPaddle(_ alert: PADAlert) -> Bool { 111 | if alert.alertType == .error, !LowtechProAppDelegate.showNextPaddleError { 112 | LowtechProAppDelegate.showNextPaddleError = true 113 | 114 | return false 115 | } 116 | 117 | return true 118 | } 119 | 120 | @MainActor 121 | public func paddleDidError(_ error: Error) { 122 | guard let code = PADErrorCode(rawValue: (error as NSError).code) else { return } 123 | 124 | switch code { 125 | case .licenseCodeUtilized, .tooManyActivationsOrExpired, .noActivations: 126 | guard let product, 127 | let s = statusBar, let window = s.window, 128 | let sheet = window.sheets.first, 129 | let paddleController = sheet.windowController as? PADActivateWindowController, 130 | let email = paddleController.emailTxt?.stringValue, 131 | let licenseCode = paddleController.licenseTxt?.stringValue 132 | else { return } 133 | 134 | LowtechProAppDelegate.showNextPaddleError = false 135 | product.activations(forLicense: licenseCode) { activations, error in 136 | guard let activationsList = activations as? [[String: Any]], let oldestActivation = activationsList.first 137 | else { return } 138 | 139 | product.deactivateActivation(oldestActivation["activation_id"] as! String, license: licenseCode) { deactivated, error in 140 | guard deactivated else { return } 141 | mainAsync { 142 | product.activateEmail(email, license: licenseCode) { didActivate, error in 143 | guard didActivate else { 144 | if let error { 145 | log.error(error.localizedDescription) 146 | paddleController.showErrorAlert(error.localizedDescription) 147 | } 148 | return 149 | } 150 | paddleController.closeDialog(.activated, internalUICloseReason: nil) 151 | } 152 | } 153 | } 154 | } 155 | default: 156 | break 157 | } 158 | } 159 | } 160 | 161 | public var paddle: Paddle? 162 | public var product: PADProduct? 163 | 164 | // MARK: - LowtechPro 165 | 166 | public class LowtechPro: ObservableObject { 167 | public init( 168 | paddleVendorID: String, 169 | paddleAPIKey: String, 170 | paddleProductID: String, 171 | productName: String, 172 | vendorName: String, 173 | price: NSNumber, 174 | currency: String, 175 | trialDays: NSNumber, 176 | trialType: PADProductTrialType, 177 | trialText: String, 178 | image: String? = nil, 179 | productDelegate: PADProductDelegate? = nil, 180 | paddleDelegate: PaddleDelegate? = nil 181 | ) { 182 | self.paddleVendorID = paddleVendorID 183 | self.paddleAPIKey = paddleAPIKey 184 | self.paddleProductID = paddleProductID 185 | self.productName = productName 186 | self.vendorName = vendorName 187 | self.price = price 188 | self.currency = currency 189 | self.trialDays = trialDays 190 | self.trialType = trialType 191 | self.trialText = trialText 192 | self.image = image 193 | self.productDelegate = productDelegate 194 | self.paddleDelegate = paddleDelegate 195 | 196 | paddle = Paddle.sharedInstance( 197 | withVendorID: paddleVendorID, apiKey: paddleAPIKey, productID: paddleProductID, 198 | configuration: productConfig, delegate: paddleDelegate 199 | ) 200 | 201 | product = PADProduct( 202 | productID: paddleProductID, productType: PADProductType.sdkProduct, 203 | configuration: productConfig 204 | ) 205 | 206 | guard let product else { 207 | return 208 | } 209 | 210 | product.delegate = productDelegate 211 | product.preventFreeUsageBeforeSubscriptionPurchase = true 212 | product.canForceExit = true 213 | product.willContinueAtTrialEnd = false 214 | 215 | if product.activated || trialActive(product: product) { 216 | enablePro() 217 | } 218 | } 219 | 220 | @Published public var onTrial = false 221 | @Published public var productActivated = false 222 | 223 | public var active: Bool { productActivated || onTrial } 224 | 225 | public func manageLicence() { 226 | guard let paddle, let product else { 227 | return 228 | } 229 | paddle.showProductAccessDialog(with: product) 230 | } 231 | 232 | public func showCheckout() { 233 | guard let paddle, let product else { 234 | return 235 | } 236 | 237 | paddle.showCheckout( 238 | for: product, options: nil, 239 | checkoutStatusCompletion: { 240 | state, _ in 241 | switch state { 242 | case .abandoned: 243 | print("Checkout abandoned") 244 | case .failed: 245 | print("Checkout failed") 246 | case .flagged: 247 | print("Checkout flagged") 248 | case .purchased: 249 | print("Checkout purchased") 250 | case .slowOrderProcessing: 251 | print("Checkout slow processing") 252 | default: 253 | print("Checkout unknown state: \(state)") 254 | } 255 | } 256 | ) 257 | } 258 | 259 | public func showLicenseActivation() { 260 | guard let paddle, let product else { 261 | return 262 | } 263 | paddle.showLicenseActivationDialog(for: product, email: nil, licenseCode: nil, activationStatusCompletion: { activationStatus in 264 | mainAsync { 265 | switch activationStatus { 266 | case .activated: 267 | self.enablePro() 268 | default: 269 | return 270 | } 271 | } 272 | }) 273 | } 274 | 275 | public func licenseExpired(_ product: PADProduct) -> Bool { 276 | product.licenseCode != nil && (product.licenseExpiryDate ?? Date.distantFuture).isInPast 277 | } 278 | 279 | public func trialActive(product: PADProduct) -> Bool { 280 | let hasTrialDaysLeft = (product.trialDaysRemaining ?? NSNumber(value: 0)).intValue > 0 281 | 282 | return hasTrialDaysLeft && (product.licenseCode == nil || licenseExpired(product)) 283 | } 284 | 285 | public func checkProLicense() { 286 | guard let product else { 287 | return 288 | } 289 | product.refresh { [self] 290 | (delta: [AnyHashable: Any]?, error: Error?) in 291 | mainAsync { [self] in 292 | if let delta, !delta.isEmpty { 293 | print("Differences in \(product.productName ?? "product") after refresh") 294 | } 295 | if let error { 296 | log.error("Error on refreshing \(product.productName ?? "product") from Paddle: \(error)") 297 | } 298 | 299 | if trialActive(product: product) || product.activated { 300 | enablePro() 301 | } 302 | 303 | verifyLicense() 304 | } 305 | } 306 | } 307 | 308 | public func verifyLicense(force: Bool = false) { 309 | guard let paddle, let product else { 310 | return 311 | } 312 | guard force || enoughTimeHasPassedSinceLastVerification(product: product) else { return } 313 | product.verifyActivation { [self] (state: PADVerificationState, error: Error?) in 314 | mainAsync { [self] in 315 | if let verificationError = error { 316 | log.error( 317 | "Error on verifying activation of \(product.productName ?? "product") from Paddle: \(verificationError.localizedDescription)" 318 | ) 319 | } 320 | 321 | onTrial = trialActive(product: product) 322 | 323 | switch state { 324 | case .noActivation: 325 | print("\(product.productName ?? "") noActivation") 326 | 327 | if onTrial { 328 | enablePro() 329 | } else { 330 | disablePro() 331 | } 332 | if !onTrial { 333 | paddle.showProductAccessDialog(with: product) 334 | } 335 | case .unableToVerify where error == nil: 336 | print("\(product.productName ?? "Product") unableToVerify (network problems)") 337 | case .unverified where error == nil: 338 | if retryUnverified { 339 | retryUnverified = false 340 | print("\(product.productName ?? "Product") unverified (revoked remotely), retrying for safe measure") 341 | asyncAfter(ms: 3000) { 342 | self.verifyLicense(force: true) 343 | } 344 | return 345 | } 346 | print("\(product.productName ?? "Product") unverified (revoked remotely)") 347 | 348 | disablePro() 349 | if !onTrial { 350 | paddle.showProductAccessDialog(with: product) 351 | } 352 | case .verified: 353 | print("\(product.productName ?? "Product") verified") 354 | enablePro() 355 | case PADVerificationState(rawValue: 2): 356 | log.error("\(product.productName ?? "Product") verification failed because of network connection: \(state)") 357 | default: 358 | print("\(product.productName ?? "Product") verification unknown state: \(state)") 359 | } 360 | } 361 | } 362 | } 363 | 364 | public func enablePro() { 365 | guard let product else { 366 | return 367 | } 368 | productActivated = true 369 | onTrial = trialActive(product: product) 370 | } 371 | 372 | public func disablePro() { 373 | guard let product else { 374 | return 375 | } 376 | productActivated = false 377 | onTrial = trialActive(product: product) 378 | } 379 | 380 | let paddleVendorID: String 381 | let paddleAPIKey: String 382 | let paddleProductID: String 383 | let productName: String 384 | let vendorName: String 385 | let price: NSNumber 386 | let currency: String 387 | let trialDays: NSNumber 388 | let trialType: PADProductTrialType 389 | let trialText: String 390 | let image: String? 391 | 392 | weak var productDelegate: PADProductDelegate? 393 | weak var paddleDelegate: PaddleDelegate? 394 | 395 | lazy var productConfig: PADProductConfiguration = { 396 | let defaultProductConfig = PADProductConfiguration() 397 | defaultProductConfig.productName = productName 398 | defaultProductConfig.vendorName = vendorName 399 | defaultProductConfig.price = price 400 | defaultProductConfig.currency = currency 401 | defaultProductConfig.imagePath = image != nil ? Bundle.main.pathForImageResource(image!) : nil 402 | defaultProductConfig.trialLength = trialDays 403 | defaultProductConfig.trialType = trialType 404 | defaultProductConfig.trialText = trialText 405 | 406 | return defaultProductConfig 407 | }() 408 | 409 | var retryUnverified = true 410 | 411 | @inline(__always) func enoughTimeHasPassedSinceLastVerification(product: PADProduct) -> Bool { 412 | guard let verifyDate = product.lastVerifyDate else { 413 | return true 414 | } 415 | if productActivated { 416 | #if DEBUG 417 | return true 418 | #else 419 | return timeSince(verifyDate) > 1.days.timeInterval 420 | #endif 421 | } else { 422 | return timeSince(verifyDate) > 5.minutes.timeInterval 423 | } 424 | } 425 | } 426 | -------------------------------------------------------------------------------- /Sources/Lowtech/OSDWindow.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import Combine 3 | import Defaults 4 | import Foundation 5 | import SwiftUI 6 | 7 | // MARK: - OSDWindow 8 | 9 | open class OSDWindow: LowtechWindow { 10 | public convenience init( 11 | swiftuiView: AnyView, 12 | releaseWhenClosed: Bool = true, 13 | level: NSWindow.Level = NSWindow.Level(CGShieldingWindowLevel().i), 14 | allSpaces: Bool = true, 15 | canScreenshot: Bool = true, 16 | screen: NSScreen? = nil, 17 | corner: ScreenCorner? = nil, 18 | allowsMouse: Bool = false 19 | ) { 20 | self.init(contentViewController: NSHostingController(rootView: swiftuiView)) 21 | 22 | screenPlacement = screen 23 | screenCorner = corner 24 | 25 | self.level = level 26 | collectionBehavior = [.stationary, .ignoresCycle, .fullScreenDisallowsTiling] 27 | if allSpaces { 28 | collectionBehavior.formUnion(.canJoinAllSpaces) 29 | } else { 30 | collectionBehavior.formUnion(.moveToActiveSpace) 31 | } 32 | if !canScreenshot { 33 | sharingType = .none 34 | } 35 | ignoresMouseEvents = !allowsMouse 36 | setAccessibilityRole(.popover) 37 | setAccessibilitySubrole(.unknown) 38 | 39 | backgroundColor = .clear 40 | contentView?.bg = .clear 41 | isOpaque = false 42 | hasShadow = false 43 | styleMask = [.fullSizeContentView] 44 | hidesOnDeactivate = false 45 | isReleasedWhenClosed = releaseWhenClosed 46 | delegate = self 47 | } 48 | 49 | open func show( 50 | at point: NSPoint? = nil, 51 | closeAfter closeMilliseconds: Int = 3050, 52 | fadeAfter fadeMilliseconds: Int = 2000, 53 | offCenter: CGFloat? = nil, 54 | verticalOffset: CGFloat? = nil, 55 | centerWindow: Bool = true, 56 | corner: ScreenCorner? = nil, 57 | margin: CGFloat? = nil, 58 | screen: NSScreen? = nil, 59 | animate: Bool = false 60 | ) { 61 | if let corner { 62 | moveToScreen(screen, corner: corner, margin: margin, animate: animate) 63 | } else if let point { 64 | withAnim(animate: animate) { w in w.setFrameOrigin(point) } 65 | } else if let screenFrame = (screen ?? NSScreen.withMouse ?? NSScreen.main)?.visibleFrame { 66 | withAnim(animate: animate) { w in 67 | w.setFrameOrigin(screenFrame.origin) 68 | if centerWindow { w.center() } 69 | if let verticalOffset { 70 | setFrameOrigin(CGPoint( 71 | x: (screenFrame.width / 2 - frame.size.width / 2) + screenFrame.origin.x, 72 | y: screenFrame.origin.y + verticalOffset 73 | )) 74 | } else if offCenter != 0 { 75 | let yOff = screenFrame.height / (offCenter ?? 2.2) 76 | w.setFrame(frame.offsetBy(dx: 0, dy: -yOff), display: false) 77 | } 78 | } 79 | } 80 | 81 | alphaValue = 1 82 | wc.showWindow(nil) 83 | if canBecomeKey { 84 | makeKeyAndOrderFront(nil) 85 | } 86 | orderFrontRegardless() 87 | 88 | closer?.cancel() 89 | guard closeMilliseconds > 0 else { return } 90 | fader = mainAsyncAfter(ms: fadeMilliseconds) { [weak self] in 91 | guard let self, isVisible else { return } 92 | NSAnimationContext.runAnimationGroup { ctx in 93 | ctx.duration = 1 94 | self.animator().alphaValue = 0.01 95 | } 96 | 97 | closer = mainAsyncAfter(ms: closeMilliseconds) { [weak self] in 98 | self?.close() 99 | } 100 | } 101 | } 102 | 103 | public func hide() { 104 | fader = nil 105 | closer = nil 106 | 107 | if let v = contentView?.superview { 108 | v.alphaValue = 0.0 109 | } 110 | close() 111 | windowController?.close() 112 | } 113 | 114 | var closer: DispatchWorkItem? { 115 | didSet { 116 | oldValue?.cancel() 117 | } 118 | } 119 | 120 | var fader: DispatchWorkItem? { 121 | didSet { 122 | oldValue?.cancel() 123 | } 124 | } 125 | } 126 | 127 | // MARK: - LowtechWindow 128 | 129 | open class LowtechWindow: NSWindow, NSWindowDelegate { 130 | open var onMouseUp: ((NSEvent) -> Void)? 131 | open var onMouseDown: ((NSEvent) -> Void)? 132 | open var onMouseDrag: ((NSEvent) -> Void)? 133 | 134 | override open func mouseDragged(with event: NSEvent) { 135 | guard !ignoresMouseEvents, let onMouseDrag else { return } 136 | onMouseDrag(event) 137 | } 138 | 139 | override open func mouseDown(with event: NSEvent) { 140 | guard !ignoresMouseEvents, let onMouseDown else { return } 141 | onMouseDown(event) 142 | } 143 | 144 | override open func mouseUp(with event: NSEvent) { 145 | guard !ignoresMouseEvents, let onMouseUp else { return } 146 | onMouseUp(event) 147 | } 148 | 149 | public var closed = true 150 | public var animateOnResize = false 151 | @Published public var screenPlacement: NSScreen? 152 | 153 | public var screenCorner: ScreenCorner? 154 | public var margin: CGFloat = 0 155 | 156 | public lazy var wc = NSWindowController(window: self) 157 | 158 | public func windowDidBecomeKey(_ notification: Notification) { 159 | closed = false 160 | } 161 | 162 | public func windowDidBecomeMain(_ notification: Notification) { 163 | closed = false 164 | } 165 | 166 | public func windowWillClose(_ notification: Notification) { 167 | closed = true 168 | } 169 | 170 | public func windowDidResize(_ notification: Notification) { 171 | guard let screenCorner, let screenPlacement else { return } 172 | moveToScreen(screenPlacement, corner: screenCorner, animate: animateOnResize) 173 | } 174 | 175 | public func resizeToScreenHeight(_ screen: NSScreen? = nil, animate: Bool = false) { 176 | guard let screenFrame = (screen ?? NSScreen.withMouse ?? NSScreen.main)?.visibleFrame else { 177 | return 178 | } 179 | withAnim(animate: animate) { w in 180 | w.setContentSize(NSSize(width: frame.width, height: screenFrame.height)) 181 | } 182 | } 183 | 184 | public func centerOnScreen(_ screen: NSScreen? = nil, animate: Bool = false) { 185 | withAnim(animate: animate) { w in 186 | if let screenFrame = (screen ?? NSScreen.withMouse ?? NSScreen.main)?.visibleFrame { 187 | w.setFrameOrigin(screenFrame.origin) 188 | } 189 | w.center() 190 | } 191 | } 192 | 193 | public func moveToScreen(_ screen: NSScreen? = nil, corner: ScreenCorner? = nil, margin: CGFloat? = nil, animate: Bool = false) { 194 | guard let screenFrame = (screen ?? NSScreen.withMouse ?? NSScreen.main)?.visibleFrame else { 195 | return 196 | } 197 | 198 | if let margin { 199 | self.margin = margin 200 | } 201 | if let screen { 202 | screenPlacement = screen 203 | } 204 | 205 | guard let corner else { 206 | setFrame(NSRect(origin: screenFrame.origin, size: frame.size), display: true, animate: animate) 207 | return 208 | } 209 | 210 | screenCorner = corner 211 | let scrO = screenFrame.origin 212 | let scrF = screenFrame 213 | 214 | switch corner { 215 | case .bottomLeft: 216 | setFrame(NSRect(origin: scrO.applying(.init(translationX: self.margin, y: self.margin)), size: frame.size), display: true, animate: animate) 217 | case .bottomRight: 218 | setFrame(NSRect( 219 | origin: 220 | NSPoint( 221 | x: (scrO.x + scrF.width) - frame.width, 222 | y: scrO.y 223 | ) 224 | .applying(.init(translationX: -self.margin, y: self.margin)), 225 | size: frame.size 226 | ), display: true, animate: animate) 227 | case .topLeft: 228 | setFrame(NSRect( 229 | origin: 230 | NSPoint( 231 | x: scrO.x, 232 | y: frame.height > scrF.height ? scrO.y : (scrO.y + scrF.height) - frame.height 233 | ) 234 | .applying(.init(translationX: self.margin, y: -self.margin)), 235 | size: frame.size 236 | ), display: true, animate: animate) 237 | case .topRight: 238 | setFrame(NSRect( 239 | origin: 240 | NSPoint( 241 | x: (scrO.x + scrF.width) - frame.width, 242 | y: frame.height > scrF.height ? scrO.y : (scrO.y + scrF.height) - frame.height 243 | ) 244 | .applying(.init(translationX: -self.margin, y: -self.margin)), 245 | size: frame.size 246 | ), display: true, animate: animate) 247 | case .top: 248 | setFrame(NSRect( 249 | origin: 250 | NSPoint( 251 | x: scrO.x + (scrF.width - frame.width) / 2, 252 | y: (scrO.y + scrF.height) - frame.height 253 | ) 254 | .applying(.init(translationX: 0, y: -self.margin)), 255 | size: frame.size 256 | ), display: true, animate: animate) 257 | case .bottom: 258 | setFrame(NSRect( 259 | origin: 260 | NSPoint( 261 | x: scrO.x + (scrF.width - frame.width) / 2, 262 | y: scrO.y 263 | ) 264 | .applying(.init(translationX: 0, y: self.margin)), 265 | size: frame.size 266 | ), display: true, animate: animate) 267 | case .left: 268 | setFrame(NSRect( 269 | origin: 270 | NSPoint( 271 | x: scrO.x, 272 | y: frame.height > scrF.height ? scrO.y : scrO.y + (scrF.height - frame.height) / 2 273 | ) 274 | .applying(.init(translationX: self.margin, y: 0)), 275 | size: frame.size 276 | ), display: true, animate: animate) 277 | case .right: 278 | setFrame(NSRect( 279 | origin: 280 | NSPoint( 281 | x: (scrO.x + scrF.width) - frame.width, 282 | y: frame.height > scrF.height ? scrO.y : scrO.y + (scrF.height - frame.height) / 2 283 | ) 284 | .applying(.init(translationX: -self.margin, y: 0)), 285 | size: frame.size 286 | ), display: true, animate: animate) 287 | case .center: 288 | setFrame(NSRect(origin: NSPoint( 289 | x: scrO.x + (scrF.width - frame.width) / 2, 290 | y: frame.height > scrF.height ? scrO.y : scrO.y + (scrF.height - frame.height) / 2 291 | ), size: frame.size), display: true, animate: animate) 292 | // w.center() 293 | } 294 | } 295 | 296 | public func moveToScreenOrig(_ screen: NSScreen? = nil, corner: ScreenCorner? = nil, margin: CGFloat? = nil, animate: Bool = false) { 297 | guard let screenFrame = (screen ?? NSScreen.withMouse ?? NSScreen.main)?.visibleFrame else { 298 | return 299 | } 300 | 301 | if let margin { 302 | self.margin = margin 303 | } 304 | if let screen { 305 | screenPlacement = screen 306 | } 307 | 308 | withAnim(animate: animate) { w in 309 | guard let corner else { 310 | w.setFrameOrigin(screenFrame.origin) 311 | return 312 | } 313 | 314 | screenCorner = corner 315 | let scrO = screenFrame.origin 316 | let scrF = screenFrame 317 | 318 | switch corner { 319 | case .bottomLeft: 320 | w.setFrameOrigin(scrO.applying(.init(translationX: self.margin, y: self.margin))) 321 | case .bottomRight: 322 | w.setFrameOrigin( 323 | NSPoint( 324 | x: (scrO.x + scrF.width) - frame.width, 325 | y: scrO.y 326 | ) 327 | .applying(.init(translationX: -self.margin, y: self.margin)) 328 | ) 329 | case .topLeft: 330 | w.setFrameOrigin( 331 | NSPoint( 332 | x: scrO.x, 333 | y: frame.height > scrF.height ? scrO.y : (scrO.y + scrF.height) - frame.height 334 | ) 335 | .applying(.init(translationX: self.margin, y: -self.margin)) 336 | ) 337 | case .topRight: 338 | w.setFrameOrigin( 339 | NSPoint( 340 | x: (scrO.x + scrF.width) - frame.width, 341 | y: frame.height > scrF.height ? scrO.y : (scrO.y + scrF.height) - frame.height 342 | ) 343 | .applying(.init(translationX: -self.margin, y: -self.margin)) 344 | ) 345 | case .top: 346 | w.setFrameOrigin( 347 | NSPoint( 348 | x: scrO.x + (scrF.width - frame.width) / 2, 349 | y: (scrO.y + scrF.height) - frame.height 350 | ) 351 | .applying(.init(translationX: 0, y: -self.margin)) 352 | ) 353 | case .bottom: 354 | w.setFrameOrigin( 355 | NSPoint( 356 | x: scrO.x + (scrF.width - frame.width) / 2, 357 | y: scrO.y 358 | ) 359 | .applying(.init(translationX: 0, y: self.margin)) 360 | ) 361 | case .left: 362 | w.setFrameOrigin( 363 | NSPoint( 364 | x: scrO.x, 365 | y: frame.height > scrF.height ? scrO.y : scrO.y + (scrF.height - frame.height) / 2 366 | ) 367 | .applying(.init(translationX: self.margin, y: 0)) 368 | ) 369 | case .right: 370 | w.setFrameOrigin( 371 | NSPoint( 372 | x: (scrO.x + scrF.width) - frame.width, 373 | y: frame.height > scrF.height ? scrO.y : scrO.y + (scrF.height - frame.height) / 2 374 | ) 375 | .applying(.init(translationX: -self.margin, y: 0)) 376 | ) 377 | case .center: 378 | w.setFrameOrigin(NSPoint( 379 | x: scrO.x + (scrF.width - frame.width) / 2, 380 | y: frame.height > scrF.height ? scrO.y : scrO.y + (scrF.height - frame.height) / 2 381 | )) 382 | // w.center() 383 | } 384 | } 385 | } 386 | 387 | public func forceClose() { 388 | wc.close() 389 | wc.window = nil 390 | close() 391 | } 392 | 393 | public func withAnim(_ easing: CAMediaTimingFunction = .easeOutExpo, duration: Double = 0.3, animate: Bool = true, onEnd: (() -> Void)? = nil, _ action: (LowtechWindow) -> Void) { 394 | guard animate else { 395 | action(self) 396 | return 397 | } 398 | NSAnimationContext.runAnimationGroup({ ctx in 399 | ctx.timingFunction = easing 400 | ctx.allowsImplicitAnimation = true 401 | ctx.duration = duration 402 | action(animator()) 403 | }, completionHandler: onEnd) 404 | } 405 | 406 | public func windowShouldClose(_ sender: NSWindow) -> Bool { 407 | guard isReleasedWhenClosed else { return true } 408 | windowController?.window = nil 409 | windowController = nil 410 | return true 411 | } 412 | 413 | @Atomic var inAnim = false 414 | } 415 | 416 | // MARK: - ScreenCorner 417 | 418 | public enum ScreenCorner: Int, Codable, Defaults.Serializable { 419 | case bottomLeft 420 | case bottomRight 421 | case topLeft 422 | case topRight 423 | 424 | case top 425 | case bottom 426 | case left 427 | case right 428 | 429 | case center 430 | } 431 | -------------------------------------------------------------------------------- /Sources/Lowtech/Util.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Util.swift 3 | // rcmd 4 | // 5 | // Created by Alin Panaitiu on 09.11.2021. 6 | // 7 | 8 | import Cocoa 9 | import Combine 10 | import Defaults 11 | import Foundation 12 | 13 | @inline(__always) 14 | public func timeSince(_ date: Date) -> TimeInterval { 15 | date.timeIntervalSinceNow * -1 16 | } 17 | 18 | @inline(__always) 19 | public func timeUntil(_ date: Date) -> TimeInterval { 20 | date.timeIntervalSinceNow 21 | } 22 | 23 | public func cap(_ number: T, minVal: T, maxVal: T) -> T { 24 | max(min(number, maxVal), minVal) 25 | } 26 | 27 | // MARK: - Formatting 28 | 29 | public struct Formatting: Hashable { 30 | public func hash(into hasher: inout Hasher) { 31 | hasher.combine(padding) 32 | hasher.combine(decimals) 33 | } 34 | 35 | let decimals: Int 36 | let padding: Int 37 | } 38 | 39 | // MARK: - ObservableSettings 40 | 41 | @MainActor 42 | public protocol ObservableSettings: AnyObject { 43 | var observers: Set { get set } 44 | var apply: Bool { get set } 45 | 46 | func initObservers() 47 | } 48 | 49 | // MARK: - SettingTransformer 50 | 51 | public struct SettingTransformer { 52 | public init(to: @escaping (Value) -> Transformed, from: ((Transformed) -> Value)? = nil) { 53 | self.to = to 54 | self.from = from 55 | } 56 | 57 | public init(to: KeyPath, from: KeyPath? = nil) { 58 | self.to = { v in v[keyPath: to] } 59 | if let from { 60 | self.from = { v in v[keyPath: from] } 61 | } else { 62 | self.from = nil 63 | } 64 | } 65 | 66 | public let to: (Value) -> Transformed 67 | public let from: ((Transformed) -> Value)? 68 | 69 | public static func to(_ k: KeyPath) -> Self { 70 | Self(to: k) 71 | } 72 | } 73 | 74 | public extension ObservableSettings { 75 | func withoutApply(_ action: () -> Void) { 76 | apply = false 77 | action() 78 | apply = true 79 | } 80 | 81 | func bind(_ key: Defaults.Key, property: ReferenceWritableKeyPath, publisher: KeyPath.Publisher>? = nil, debounce: RunLoop.SchedulerTimeType.Stride? = nil) { 82 | let onSettingChange: (Defaults.KeyChange) -> Void = { [weak self] change in 83 | guard let self else { return } 84 | withoutApply { 85 | self[keyPath: property] = change.newValue 86 | } 87 | } 88 | 89 | if let debounce { 90 | Defaults.publisher(key) 91 | .debounce(for: debounce, scheduler: RunLoop.main) 92 | .sink(receiveValue: onSettingChange).store(in: &observers) 93 | } else { 94 | Defaults.publisher(key) 95 | .receive(on: RunLoop.main) 96 | .sink(receiveValue: onSettingChange).store(in: &observers) 97 | } 98 | 99 | guard let publisher else { return } 100 | 101 | let onChange: (Value) -> Void = { [weak self] val in 102 | guard let self, apply else { return } 103 | Defaults.withoutPropagation { 104 | Defaults[key] = val 105 | } 106 | } 107 | 108 | if let debounce { 109 | self[keyPath: publisher] 110 | .debounce(for: debounce, scheduler: RunLoop.main) 111 | .sink(receiveValue: onChange).store(in: &observers) 112 | } else { 113 | self[keyPath: publisher] 114 | .receive(on: RunLoop.main) 115 | .sink(receiveValue: onChange).store(in: &observers) 116 | } 117 | } 118 | 119 | func bind( 120 | _ key: Defaults.Key, 121 | property: ReferenceWritableKeyPath, 122 | publisher: KeyPath.Publisher>? = nil, 123 | debounce: RunLoop.SchedulerTimeType.Stride? = nil, 124 | transformer: SettingTransformer 125 | ) { 126 | let onSettingChange: (Defaults.KeyChange) -> Void = { [weak self] change in 127 | guard let self else { return } 128 | withoutApply { 129 | self[keyPath: property] = transformer.to(change.newValue) 130 | } 131 | } 132 | 133 | if let debounce { 134 | Defaults.publisher(key) 135 | .debounce(for: debounce, scheduler: RunLoop.main) 136 | .sink(receiveValue: onSettingChange).store(in: &observers) 137 | } else { 138 | Defaults.publisher(key) 139 | .receive(on: RunLoop.main) 140 | .sink(receiveValue: onSettingChange).store(in: &observers) 141 | } 142 | 143 | guard let publisher else { return } 144 | 145 | let onChange: (Transformed) -> Void = { [weak self] val in 146 | guard let self, apply, let transform = transformer.from else { return } 147 | Defaults.withoutPropagation { 148 | Defaults[key] = transform(val) 149 | } 150 | } 151 | 152 | if let debounce { 153 | self[keyPath: publisher] 154 | .debounce(for: debounce, scheduler: RunLoop.main) 155 | .sink(receiveValue: onChange).store(in: &observers) 156 | } else { 157 | self[keyPath: publisher] 158 | .receive(on: RunLoop.main) 159 | .sink(receiveValue: onChange).store(in: &observers) 160 | } 161 | } 162 | } 163 | 164 | // MARK: - Setting 165 | 166 | @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 167 | @propertyWrapper 168 | public class Setting { 169 | public init(_ key: Defaults.Key) { 170 | self.key = key 171 | storage = Storage(initialValue: Defaults[key]) 172 | observer = Defaults.publisher(key) 173 | .debounce(for: .milliseconds(10), scheduler: RunLoop.main) 174 | .sink { [weak self] in 175 | guard let self else { return } 176 | storage.value = $0.newValue 177 | storage.oldValue = $0.oldValue 178 | } 179 | } 180 | 181 | public typealias Publisher = AnyPublisher, Never> 182 | 183 | public var wrappedValue: Value { 184 | get { storage.value } 185 | set { 186 | storage.oldValue = storage.value 187 | storage.value = newValue 188 | Defaults.withoutPropagation { 189 | Defaults[key] = newValue 190 | } 191 | } 192 | } 193 | 194 | private class Storage { 195 | init(initialValue: Value) { 196 | value = initialValue 197 | } 198 | 199 | var value: Value 200 | var oldValue: Value? 201 | } 202 | 203 | private var observer: Cancellable? 204 | private var storage: Storage 205 | private let key: Defaults.Key 206 | } 207 | 208 | @discardableResult 209 | @inline(__always) public func mainThread(_ action: () -> T) -> T { 210 | guard !Thread.isMainThread else { 211 | return action() 212 | } 213 | return DispatchQueue.main.sync { action() } 214 | } 215 | 216 | @inline(__always) public func mainAsync(_ action: @escaping () -> Void) { 217 | guard !Thread.isMainThread else { 218 | action() 219 | return 220 | } 221 | DispatchQueue.main.async { action() } 222 | } 223 | 224 | @discardableResult 225 | public func mainAsyncAfter(ms: Int, _ action: @escaping () -> Void) -> DispatchWorkItem { 226 | let deadline = DispatchTime(uptimeNanoseconds: DispatchTime.now().uptimeNanoseconds + UInt64(ms * 1_000_000)) 227 | 228 | let workItem = DispatchWorkItem { 229 | action() 230 | } 231 | DispatchQueue.main.asyncAfter(deadline: deadline, execute: workItem) 232 | 233 | return workItem 234 | } 235 | 236 | @discardableResult 237 | public func asyncAfter(ms: Int, _ action: @escaping () -> Void) -> DispatchWorkItem { 238 | let workItem = DispatchWorkItem(block: action) 239 | asyncAfter(ms: ms, workItem) 240 | 241 | return workItem 242 | } 243 | 244 | public extension DispatchWorkItem { 245 | func wait(for timeout: TimeInterval) -> DispatchTimeoutResult { 246 | let result = wait(timeout: .now() + timeout) 247 | if result == .timedOut { 248 | cancel() 249 | return .timedOut 250 | } 251 | return .success 252 | } 253 | } 254 | 255 | @discardableResult 256 | public func asyncNow(timeout _: TimeInterval? = nil, _ action: @escaping () -> Void) -> DispatchWorkItem { 257 | let workItem = DispatchWorkItem(block: action) 258 | 259 | DispatchQueue.global().async(execute: workItem) 260 | return workItem 261 | } 262 | 263 | public func asyncAfter(ms: Int, _ action: DispatchWorkItem) { 264 | let deadline = DispatchTime(uptimeNanoseconds: DispatchTime.now().uptimeNanoseconds + UInt64(ms * 1_000_000)) 265 | 266 | DispatchQueue.global().asyncAfter(deadline: deadline, execute: action) 267 | } 268 | 269 | public func createWindow( 270 | _ identifier: String, 271 | controller: inout NSWindowController?, 272 | screen: NSScreen? = nil, 273 | show: Bool = true, 274 | backgroundColor: NSColor? = .clear, 275 | level: NSWindow.Level = .normal, 276 | fillScreen: Bool = false, 277 | stationary: Bool = false 278 | ) { 279 | mainThread { 280 | guard let mainStoryboard = NSStoryboard.main else { return } 281 | 282 | if controller == nil { 283 | controller = mainStoryboard 284 | .instantiateController(withIdentifier: NSStoryboard.SceneIdentifier(identifier)) as? NSWindowController 285 | } 286 | 287 | if let wc = controller { 288 | if let screen, let w = wc.window { 289 | w.setFrameOrigin(CGPoint(x: screen.frame.minX, y: screen.frame.minY)) 290 | if fillScreen { 291 | w.setFrame(screen.frame, display: false) 292 | } 293 | } 294 | 295 | if let window = wc.window { 296 | window.level = level 297 | window.isOpaque = false 298 | window.backgroundColor = backgroundColor 299 | if stationary { 300 | window.collectionBehavior = [.stationary, .canJoinAllSpaces, .ignoresCycle, .fullScreenDisallowsTiling, .fullScreenNone] 301 | window.sharingType = .none 302 | window.ignoresMouseEvents = true 303 | window.setAccessibilityRole(.popover) 304 | window.setAccessibilitySubrole(.unknown) 305 | } 306 | if show { 307 | wc.showWindow(nil) 308 | window.orderFrontRegardless() 309 | } 310 | } 311 | } 312 | } 313 | } 314 | 315 | public func createTransition( 316 | duration: TimeInterval, 317 | type: CATransitionType, 318 | subtype: CATransitionSubtype = .fromTop, 319 | start: Float = 0.0, 320 | end: Float = 1.0, 321 | easing: CAMediaTimingFunction = .easeOutQuart 322 | ) -> CATransition { 323 | let transition = CATransition() 324 | transition.duration = duration 325 | transition.type = type 326 | transition.subtype = subtype 327 | transition.startProgress = start 328 | transition.endProgress = end 329 | transition.timingFunction = easing 330 | return transition 331 | } 332 | 333 | public func mapNumber(_ number: T, fromLow: T, fromHigh: T, toLow: T, toHigh: T) -> T { 334 | if fromLow == fromHigh { 335 | print("fromLow and fromHigh are both equal to \(fromLow)") 336 | return number 337 | } 338 | 339 | if number >= fromHigh { 340 | return toHigh 341 | } else if number <= fromLow { 342 | return toLow 343 | } else if toLow < toHigh { 344 | let diff = toHigh - toLow 345 | let fromDiff = fromHigh - fromLow 346 | return (number - fromLow) * diff / fromDiff + toLow 347 | } else { 348 | let diff = toHigh - toLow 349 | let fromDiff = fromHigh - fromLow 350 | return (number - fromLow) * diff / fromDiff + toLow 351 | } 352 | } 353 | 354 | public extension Double { 355 | func map(from: (Double, Double), to: (Double, Double)) -> Double { 356 | lerp(invlerp(self, min: from.0, max: from.1), min: to.0, max: to.1) 357 | } 358 | 359 | func map(from: (Double, Double), to: (Double, Double), gamma: Double) -> Double { 360 | lerp(pow(invlerp(self, min: from.0, max: from.1), 1.0 / gamma), min: to.0, max: to.1) 361 | } 362 | } 363 | 364 | public extension Float { 365 | func map(from: (Float, Float), to: (Float, Float)) -> Float { 366 | lerp(invlerp(self, min: from.0, max: from.1), min: to.0, max: to.1) 367 | } 368 | 369 | func map(from: (Float, Float), to: (Float, Float), gamma: Float) -> Float { 370 | lerp(pow(invlerp(self, min: from.0, max: from.1), 1.0 / gamma), min: to.0, max: to.1) 371 | } 372 | } 373 | 374 | public extension CGFloat { 375 | func map(from: (CGFloat, CGFloat), to: (CGFloat, CGFloat)) -> CGFloat { 376 | lerp(invlerp(self, min: from.0, max: from.1), min: to.0, max: to.1) 377 | } 378 | 379 | func map(from: (CGFloat, CGFloat), to: (CGFloat, CGFloat), gamma: CGFloat) -> CGFloat { 380 | lerp(pow(invlerp(self, min: from.0, max: from.1), 1.0 / gamma), min: to.0, max: to.1) 381 | } 382 | } 383 | 384 | @inline(__always) 385 | public func lerp(_ value: Double, min: Double, max: Double) -> Double { 386 | min + (max - min) * value 387 | } 388 | 389 | @inline(__always) 390 | public func invlerp(_ value: Double, min: Double, max: Double) -> Double { 391 | max == min ? min : (value - min) / (max - min) 392 | } 393 | 394 | @inline(__always) 395 | public func lerp(_ value: Float, min: Float, max: Float) -> Float { 396 | min + (max - min) * value 397 | } 398 | 399 | @inline(__always) 400 | public func invlerp(_ value: Float, min: Float, max: Float) -> Float { 401 | max == min ? min : (value - min) / (max - min) 402 | } 403 | 404 | @inline(__always) 405 | public func lerp(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat { 406 | min + (max - min) * value 407 | } 408 | 409 | @inline(__always) 410 | public func invlerp(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat { 411 | max == min ? min : (value - min) / (max - min) 412 | } 413 | 414 | @inline(__always) 415 | public func lerpGamma22(_ value: Double, min: Double, max: Double) -> Double { 416 | let normalizedValue = invlerp(value, min: min, max: max) 417 | return pow(normalizedValue, 2.2) * (max - min) + min 418 | } 419 | 420 | @inline(__always) 421 | public func lerpGamma(_ value: Double, min: Double, max: Double, gamma: Double) -> Double { 422 | let normalizedValue = invlerp(value, min: min, max: max) 423 | return pow(normalizedValue, gamma) * (max - min) + min 424 | } 425 | 426 | @inline(__always) 427 | public func invlerpGamma22(_ interpolated: Double, min: Double, max: Double) -> Double { 428 | let normalizedValue = pow(invlerp(interpolated, min: min, max: max), 1.0 / 2.2) 429 | return normalizedValue * (max - min) + min 430 | } 431 | 432 | @inline(__always) 433 | public func invlerpGamma(_ interpolated: Double, min: Double, max: Double, gamma: Double) -> Double { 434 | let normalizedValue = pow(invlerp(interpolated, min: min, max: max), 1.0 / gamma) 435 | return normalizedValue * (max - min) + min 436 | } 437 | 438 | public let NO_SHADOW: NSShadow = { 439 | let s = NSShadow() 440 | s.shadowColor = .clear 441 | s.shadowOffset = .zero 442 | s.shadowBlurRadius = 0 443 | return s 444 | }() 445 | 446 | // MARK: - IndexedCollection 447 | 448 | public struct IndexedCollection: RandomAccessCollection where Base.Element: Hashable { 449 | public typealias Index = Base.Index 450 | public typealias Element = (index: Index, element: Base.Element) where Base.Element: Hashable 451 | 452 | public var startIndex: Index { base.startIndex } 453 | public var endIndex: Index { base.endIndex } 454 | 455 | public func index(after i: Index) -> Index { 456 | base.index(after: i) 457 | } 458 | 459 | public func index(before i: Index) -> Index { 460 | base.index(before: i) 461 | } 462 | 463 | public func index(_ i: Index, offsetBy distance: Int) -> Index { 464 | base.index(i, offsetBy: distance) 465 | } 466 | 467 | public subscript(position: Index) -> Element { 468 | (index: position, element: base[position]) 469 | } 470 | 471 | let base: Base 472 | } 473 | 474 | public extension RandomAccessCollection where Element: Hashable { 475 | func indexed() -> IndexedCollection { 476 | IndexedCollection(base: self) 477 | } 478 | } 479 | 480 | public func promptForWorkingDirectoryPermission( 481 | message: String = "Choose your working directory", 482 | prompt: String = "Choose", 483 | initialPath: URL = FileManager.default.homeDirectoryForCurrentUser.deletingLastPathComponent(), 484 | defaultsKey: Defaults.Key? = nil 485 | ) -> URL? { 486 | let openPanel = NSOpenPanel() 487 | openPanel.message = message 488 | openPanel.prompt = prompt 489 | openPanel.resolvesAliases = true 490 | openPanel.allowedFileTypes = ["none"] 491 | openPanel.allowsOtherFileTypes = false 492 | openPanel.allowsMultipleSelection = false 493 | openPanel.canChooseFiles = false 494 | openPanel.canChooseDirectories = true 495 | openPanel.directoryURL = initialPath 496 | 497 | let result = openPanel.runModal() 498 | guard let url = openPanel.urls.first else { return nil } 499 | 500 | switch result { 501 | case .cancel, .abort: 502 | return nil 503 | case .OK: 504 | saveBookmarkData(for: url, defaultsKey: defaultsKey) 505 | return url 506 | default: 507 | return nil 508 | } 509 | } 510 | 511 | public func promptForFilePermission( 512 | message: String = "Choose a file", 513 | prompt: String = "Choose", 514 | initialPath: URL = FileManager.default.homeDirectoryForCurrentUser, 515 | defaultsKey: Defaults.Key? = nil 516 | ) -> URL? { 517 | let openPanel = NSOpenPanel() 518 | openPanel.message = message 519 | openPanel.prompt = prompt 520 | openPanel.resolvesAliases = true 521 | openPanel.allowsOtherFileTypes = false 522 | openPanel.allowsMultipleSelection = false 523 | openPanel.canChooseFiles = true 524 | openPanel.canChooseDirectories = true 525 | openPanel.directoryURL = initialPath 526 | 527 | let result = openPanel.runModal() 528 | guard let url = openPanel.urls.first else { return nil } 529 | 530 | switch result { 531 | case .cancel, .abort: 532 | return nil 533 | case .OK: 534 | saveBookmarkData(for: url, defaultsKey: defaultsKey) 535 | return url 536 | default: 537 | return nil 538 | } 539 | } 540 | 541 | public func saveBookmarkData(for workDir: URL, defaultsKey: Defaults.Key? = nil) { 542 | do { 543 | let bookmarkData = try workDir.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) 544 | 545 | Defaults[defaultsKey ?? Defaults.Key("\(workDir.path.sha1)-BookmarkData")] = bookmarkData 546 | } catch { 547 | err("Failed to save bookmark data for \(workDir): \(error)") 548 | } 549 | } 550 | 551 | public func restoreFileAccess(for workDir: URL? = nil, defaultsKey: Defaults.Key? = nil) -> URL? { 552 | guard let bookmarkData = Defaults[defaultsKey ?? Defaults.Key("\(workDir?.path.sha1 ?? "")-BookmarkData")] else { return nil } 553 | 554 | do { 555 | var isStale = false 556 | let url = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) 557 | if isStale { 558 | debug("Bookmark is stale, need to save a new one... ") 559 | saveBookmarkData(for: url, defaultsKey: defaultsKey) 560 | } 561 | return url 562 | } catch { 563 | err("Error resolving bookmark: \(error)") 564 | return nil 565 | } 566 | } 567 | 568 | public extension URL { 569 | @discardableResult 570 | func withScopedAccess(fileChoiceMessage: String = "Choose a file", _ action: (URL) -> T) -> T? { 571 | guard let url = restoreFileAccess(for: self) ?? promptForFilePermission(message: fileChoiceMessage, initialPath: self), url.startAccessingSecurityScopedResource() 572 | else { return nil } 573 | 574 | let result = action(url) 575 | url.stopAccessingSecurityScopedResource() 576 | return result 577 | } 578 | } 579 | 580 | public let SWIFTUI_PREVIEW = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 581 | -------------------------------------------------------------------------------- /Sources/Lowtech/Styles.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Styles.swift 3 | // Volum 4 | // 5 | // Created by Alin Panaitiu on 16.12.2021. 6 | // 7 | 8 | // import DynamicColor 9 | import Foundation 10 | import SwiftUI 11 | 12 | // MARK: - CheckboxToggleStyle 13 | 14 | // import SystemColors 15 | 16 | public struct CheckboxToggleStyle: ToggleStyle { 17 | public init(style: Style = .circle, scale: Image.Scale = .large, color: Color? = nil) { 18 | self.style = style 19 | self.scale = scale 20 | self.color = color 21 | } 22 | 23 | public enum Style { 24 | case square, circle 25 | 26 | public var sfSymbolName: String { 27 | switch self { 28 | case .square: 29 | "square" 30 | case .circle: 31 | "circle" 32 | } 33 | } 34 | } 35 | 36 | @Environment(\.isEnabled) public var isEnabled 37 | public let style: Style 38 | public let scale: Image.Scale 39 | public let color: Color? 40 | 41 | public func makeBody(configuration: Configuration) -> some View { 42 | Button(action: { 43 | configuration.isOn.toggle() // toggle the state binding 44 | }, label: { 45 | HStack { 46 | Image(systemName: configuration.isOn ? "checkmark.\(style.sfSymbolName).fill" : style.sfSymbolName) 47 | .imageScale(scale) 48 | .foregroundColor(color) 49 | configuration.label 50 | } 51 | }) 52 | .buttonStyle(PlainButtonStyle()) // remove any implicit styling from the button 53 | .disabled(!isEnabled) 54 | } 55 | } 56 | 57 | // MARK: - DetailToggleStyle 58 | 59 | public struct DetailToggleStyle: ToggleStyle { 60 | public init(style: Style = .circle) { 61 | self.style = style 62 | } 63 | 64 | public enum Style { 65 | case square, circle, empty 66 | 67 | public var sfSymbolName: String { 68 | switch self { 69 | case .empty: 70 | "" 71 | case .square: 72 | ".square" 73 | case .circle: 74 | ".circle" 75 | } 76 | } 77 | } 78 | 79 | @Environment(\.isEnabled) public var isEnabled 80 | public let style: Style // custom param 81 | 82 | public func makeBody(configuration: Configuration) -> some View { 83 | Button(action: { 84 | configuration.isOn.toggle() // toggle the state binding 85 | }, label: { 86 | HStack(spacing: 3) { 87 | Image( 88 | systemName: configuration 89 | .isOn ? "arrowtriangle.up\(style.sfSymbolName).fill" : "arrowtriangle.down\(style.sfSymbolName).fill" 90 | ) 91 | .imageScale(.medium) 92 | configuration.label 93 | } 94 | }) 95 | .contentShape(Rectangle()) 96 | .buttonStyle(PlainButtonStyle()) // remove any implicit styling from the button 97 | .disabled(!isEnabled) 98 | } 99 | } 100 | 101 | // MARK: - OutlineButton 102 | 103 | public struct OutlineButton: ButtonStyle { 104 | public init( 105 | color: Color = Color.primary.opacity(0.8), 106 | hoverColor: Color = Color.primary, 107 | multiplyColor: Color = Color.white, 108 | scale: CGFloat = 1, 109 | font: Font = .body.bold() 110 | ) { 111 | _color = State(initialValue: color) 112 | _hoverColor = State(initialValue: hoverColor) 113 | _multiplyColor = State(initialValue: multiplyColor) 114 | _scale = State(initialValue: scale) 115 | _font = State(initialValue: font) 116 | } 117 | 118 | @Environment(\.isEnabled) public var isEnabled 119 | 120 | public func makeBody(configuration: Configuration) -> some View { 121 | configuration 122 | .label 123 | .font(font) 124 | .foregroundColor(color) 125 | .padding(.vertical, 2.0) 126 | .padding(.horizontal, 8.0) 127 | .background( 128 | RoundedRectangle( 129 | cornerRadius: 8, 130 | style: .continuous 131 | ).stroke(color, lineWidth: 2) 132 | ).scaleEffect(scale).colorMultiply(multiplyColor) 133 | .contentShape(Rectangle()) 134 | .onHover(perform: { hover in 135 | guard isEnabled else { return } 136 | withAnimation(.easeOut(duration: 0.2)) { 137 | multiplyColor = hover ? hoverColor : .white 138 | scale = hover ? 1.02 : 1.0 139 | } 140 | }) 141 | .onChange(of: isEnabled) { e in 142 | if !e { 143 | withAnimation(.easeOut(duration: 0.2)) { 144 | multiplyColor = .white 145 | scale = 1.0 146 | } 147 | } 148 | } 149 | } 150 | 151 | @State var color = Color.primary.opacity(0.8) 152 | @State var hoverColor: Color = .primary 153 | @State var multiplyColor: Color = .white 154 | @State var scale: CGFloat = 1 155 | @State var font: Font = .body.bold() 156 | } 157 | 158 | public extension Font { 159 | static func mono(_ size: CGFloat, weight: Font.Weight = .medium) -> Font { 160 | .system(size: size, weight: weight, design: .monospaced) 161 | } 162 | 163 | static func round(_ size: CGFloat, weight: Font.Weight = .medium) -> Font { 164 | .system(size: size, weight: weight, design: .rounded) 165 | } 166 | 167 | static func serif(_ size: CGFloat, weight: Font.Weight = .medium) -> Font { 168 | .system(size: size, weight: weight, design: .serif) 169 | } 170 | 171 | static func ultraLight(_ size: CGFloat) -> Font { 172 | .system(size: size, weight: .ultraLight, design: .default) 173 | } 174 | 175 | static func light(_ size: CGFloat) -> Font { 176 | .system(size: size, weight: .light, design: .default) 177 | } 178 | 179 | static func thin(_ size: CGFloat) -> Font { 180 | .system(size: size, weight: .thin, design: .default) 181 | } 182 | 183 | static func regular(_ size: CGFloat) -> Font { 184 | .system(size: size, weight: .regular, design: .default) 185 | } 186 | 187 | static func medium(_ size: CGFloat) -> Font { 188 | .system(size: size, weight: .medium, design: .default) 189 | } 190 | 191 | static func semibold(_ size: CGFloat) -> Font { 192 | .system(size: size, weight: .semibold, design: .default) 193 | } 194 | 195 | static func bold(_ size: CGFloat) -> Font { 196 | .system(size: size, weight: .bold, design: .default) 197 | } 198 | 199 | static func heavy(_ size: CGFloat) -> Font { 200 | .system(size: size, weight: .heavy, design: .default) 201 | } 202 | 203 | static func black(_ size: CGFloat) -> Font { 204 | .system(size: size, weight: .black, design: .default) 205 | } 206 | } 207 | 208 | public extension Text { 209 | func mono(_ size: CGFloat, weight: Font.Weight = .medium) -> Text { 210 | font(.system(size: size, weight: weight, design: .monospaced)) 211 | } 212 | 213 | func round(_ size: CGFloat, weight: Font.Weight = .medium) -> Text { 214 | font(.system(size: size, weight: weight, design: .rounded)) 215 | } 216 | 217 | func serif(_ size: CGFloat, weight: Font.Weight = .medium) -> Text { 218 | font(.system(size: size, weight: weight, design: .serif)) 219 | } 220 | 221 | func ultraLight(_ size: CGFloat) -> Text { 222 | font(.system(size: size, weight: .ultraLight, design: .default)) 223 | } 224 | 225 | func light(_ size: CGFloat) -> Text { 226 | font(.system(size: size, weight: .light, design: .default)) 227 | } 228 | 229 | func thin(_ size: CGFloat) -> Text { 230 | font(.system(size: size, weight: .thin, design: .default)) 231 | } 232 | 233 | func regular(_ size: CGFloat) -> Text { 234 | font(.system(size: size, weight: .regular, design: .default)) 235 | } 236 | 237 | func medium(_ size: CGFloat) -> Text { 238 | font(.system(size: size, weight: .medium, design: .default)) 239 | } 240 | 241 | func semibold(_ size: CGFloat) -> Text { 242 | font(.system(size: size, weight: .semibold, design: .default)) 243 | } 244 | 245 | func bold(_ size: CGFloat) -> Text { 246 | font(.system(size: size, weight: .bold, design: .default)) 247 | } 248 | 249 | func heavy(_ size: CGFloat) -> Text { 250 | font(.system(size: size, weight: .heavy, design: .default)) 251 | } 252 | 253 | func black(_ size: CGFloat) -> Text { 254 | font(.system(size: size, weight: .black, design: .default)) 255 | } 256 | 257 | func roundbg(size: CGFloat = 2.5, color: Color = .primary, colorBinding: Binding? = nil, shadowSize: CGFloat = 0, noFG: Bool = false) -> some View { 258 | modifier(RoundBG(radius: size, color: colorBinding ?? .constant(color), shadowSize: shadowSize, noFG: noFG)) 259 | } 260 | 261 | func roundbg(radius: CGFloat = 5, padding: CGFloat = 2.5, color: Color = .primary, colorBinding: Binding? = nil, shadowSize: CGFloat = 0, noFG: Bool = false) -> some View { 262 | modifier(RoundBG(radius: radius, verticalPadding: padding, horizontalPadding: padding * 2.2, color: colorBinding ?? .constant(color), shadowSize: shadowSize, noFG: noFG)) 263 | } 264 | 265 | func roundbg(radius: CGFloat = 5, verticalPadding: CGFloat = 2.5, horizontalPadding: CGFloat = 6, color: Color = .primary, colorBinding: Binding? = nil, shadowSize: CGFloat = 0, noFG: Bool = false) -> some View { 266 | modifier(RoundBG(radius: radius, verticalPadding: verticalPadding, horizontalPadding: horizontalPadding, color: colorBinding ?? .constant(color), shadowSize: shadowSize, noFG: noFG)) 267 | } 268 | } 269 | 270 | // MARK: - RoundBG 271 | 272 | public struct RoundBG: ViewModifier { 273 | public func body(content: Content) -> some View { 274 | let verticalPadding = verticalPadding ?? radius / 2 275 | content 276 | .padding(.horizontal, horizontalPadding ?? verticalPadding * 2.2) 277 | .padding(.vertical, verticalPadding) 278 | .background( 279 | roundRect(radius, fill: color) 280 | .shadow(color: .black.opacity(colorScheme == .dark ? 0.75 : 0.25), radius: shadowSize, x: 0, y: shadowSize / 2) 281 | ) 282 | .if(!noFG) { $0.foregroundColor(color.textColor(colors: colors)) } 283 | } 284 | 285 | @Environment(\.colorScheme) var colorScheme 286 | @Environment(\.colors) var colors 287 | 288 | @State var radius: CGFloat 289 | @State var verticalPadding: CGFloat? 290 | @State var horizontalPadding: CGFloat? 291 | @Binding var color: Color 292 | @State var shadowSize: CGFloat 293 | @State var noFG: Bool 294 | } 295 | 296 | public extension View { 297 | func roundbg(size: CGFloat = 2.5, color: Color = .primary, colorBinding: Binding? = nil, shadowSize: CGFloat = 0, noFG: Bool = false) -> some View { 298 | modifier(RoundBG(radius: size, color: colorBinding ?? .constant(color), shadowSize: shadowSize, noFG: noFG)) 299 | } 300 | 301 | func roundbg(radius: CGFloat = 5, padding: CGFloat = 2.5, color: Color = .primary, colorBinding: Binding? = nil, shadowSize: CGFloat = 0, noFG: Bool = false) -> some View { 302 | modifier(RoundBG(radius: radius, verticalPadding: padding, horizontalPadding: padding * 2.2, color: colorBinding ?? .constant(color), shadowSize: shadowSize, noFG: noFG)) 303 | } 304 | 305 | func roundbg(radius: CGFloat = 5, verticalPadding: CGFloat = 2.5, horizontalPadding: CGFloat = 6, color: Color = .primary, colorBinding: Binding? = nil, shadowSize: CGFloat = 0, noFG: Bool = false) -> some View { 306 | modifier(RoundBG(radius: radius, verticalPadding: verticalPadding, horizontalPadding: horizontalPadding, color: colorBinding ?? .constant(color), shadowSize: shadowSize, noFG: noFG)) 307 | } 308 | 309 | func hfill(_ alignment: Alignment = .center) -> some View { 310 | frame(maxWidth: .infinity, alignment: alignment) 311 | } 312 | 313 | func vfill(_ alignment: Alignment = .center) -> some View { 314 | frame(maxHeight: .infinity, alignment: alignment) 315 | } 316 | 317 | func fill(_ alignment: Alignment = .center) -> some View { 318 | frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) 319 | } 320 | } 321 | 322 | public func roundRect(_ radius: CGFloat, fill: Color) -> some View { 323 | RoundedRectangle(cornerRadius: radius, style: .continuous) 324 | .fill(fill) 325 | } 326 | 327 | public func roundRect(_ radius: CGFloat, stroke: Color) -> some View { 328 | RoundedRectangle(cornerRadius: radius, style: .continuous) 329 | .stroke(stroke) 330 | } 331 | 332 | // MARK: - ToggleButton 333 | 334 | public struct ToggleButton: ButtonStyle { 335 | public init( 336 | isOn: Binding, 337 | color: Color = Color.primary, 338 | textColor: Color = Color.primary, 339 | scale: CGFloat = 1, 340 | radius: CGFloat? = nil, 341 | width: CGFloat? = nil, 342 | height: CGFloat? = nil, 343 | horizontalPadding: CGFloat = 8.0, 344 | verticalPadding: CGFloat = 4.0, 345 | noFG: Bool = false 346 | ) { 347 | _color = State(initialValue: color) 348 | _scale = State(initialValue: scale) 349 | _width = State(initialValue: width) 350 | _height = State(initialValue: height) 351 | _horizontalPadding = State(initialValue: horizontalPadding) 352 | _verticalPadding = State(initialValue: verticalPadding) 353 | _radius = radius?.state ?? (height != nil ? height! * 0.4 : 8).state 354 | _isOn = isOn 355 | _noFG = State(initialValue: noFG) 356 | } 357 | 358 | @Environment(\.isEnabled) public var isEnabled 359 | 360 | public func makeBody(configuration: Configuration) -> some View { 361 | configuration 362 | .label 363 | .if(!noFG) { $0.foregroundColor(fgColor(configuration)) } 364 | .padding(.vertical, verticalPadding) 365 | .padding(.horizontal, horizontalPadding) 366 | .background( 367 | roundRect(radius, fill: bgColor(configuration)) 368 | .frame(width: width, height: height, alignment: .center) 369 | ) 370 | .brightness(hovering ? 0.05 : 0.0) 371 | .contrast(hovering ? 1.02 : 1.0) 372 | .scaleEffect(configuration.isPressed ? 1.02 : (hovering ? 1.05 : 1)) 373 | .contentShape(Rectangle()) 374 | .onHover(perform: { hover in 375 | guard isEnabled else { return } 376 | withAnimation(.easeOut(duration: 0.1)) { 377 | hovering = hover 378 | } 379 | }).onChange(of: isEnabled) { e in 380 | if !e { 381 | withAnimation(.easeOut(duration: 0.1)) { 382 | hovering = false 383 | } 384 | } 385 | } 386 | .opacity(isEnabled ? 1 : 0.6) 387 | } 388 | 389 | @Environment(\.colors) var colors 390 | 391 | @State var color: Color = .primary 392 | @State var textColor: Color = .primary 393 | @State var scale: CGFloat = 1 394 | @State var width: CGFloat? = nil 395 | @State var height: CGFloat? = nil 396 | @State var radius: CGFloat = 10 397 | @State var horizontalPadding: CGFloat = 8.0 398 | @State var verticalPadding: CGFloat = 4.0 399 | @State var hovering = false 400 | @State var noFG = false 401 | 402 | @Binding var isOn: Bool 403 | 404 | func bgColor(_ configuration: Configuration) -> Color { 405 | hovering ? (isOn ? color.opacity(0.8) : color.opacity(0.2)) : (isOn ? color.opacity(0.75) : color.opacity(0.15)) 406 | } 407 | 408 | func fgColor(_ configuration: Configuration) -> Color { 409 | guard isOn else { return .primary } 410 | return color.textColor(colors: colors) 411 | } 412 | } 413 | 414 | extension Color { 415 | var ns: NSColor { 416 | NSColor(self) 417 | } 418 | } 419 | 420 | // MARK: - PickerButton 421 | 422 | public struct PickerButton: ButtonStyle { 423 | public init( 424 | color: Color = Color.primary.opacity(0.15), 425 | onColor: Color = .primary, 426 | offColor: Color? = nil, 427 | onTextColor: Color? = nil, 428 | offTextColor: Color = Color.secondary, 429 | horizontalPadding: CGFloat = 8, 430 | verticalPadding: CGFloat = 4, 431 | brightness: Double = 0.0, 432 | scale: CGFloat = 1, 433 | radius: CGFloat = 8, 434 | hoverColor: Color = .white.opacity(0.15), 435 | enumValue: Binding, 436 | onValue: T 437 | ) { 438 | _color = color.state 439 | _onColor = onColor.state 440 | _offColor = offColor.state 441 | _offTextColor = offTextColor.state 442 | _horizontalPadding = horizontalPadding.state 443 | _verticalPadding = verticalPadding.state 444 | _brightness = brightness.state 445 | _scale = scale.state 446 | _radius = radius.state 447 | _enumValue = enumValue 448 | _onValue = st(onValue) 449 | 450 | _onTextColor = onTextColor.state 451 | 452 | _hoverColor = State(initialValue: hoverColor) 453 | _hoverTextColor = State(initialValue: hoverColor.ns.isLight() ? Color.black : Color.white) 454 | } 455 | 456 | @Environment(\.isEnabled) public var isEnabled 457 | 458 | public func makeBody(configuration: Configuration) -> some View { 459 | configuration 460 | .label 461 | .foregroundColor( 462 | hovering 463 | ? hoverTextColor 464 | : ( 465 | enumValue == onValue 466 | ? (onTextColor ?? colors.inverted) 467 | : offTextColor 468 | ) 469 | ) 470 | .padding(.vertical, verticalPadding) 471 | .padding(.horizontal, horizontalPadding) 472 | .background( 473 | RoundedRectangle( 474 | cornerRadius: radius, 475 | style: .continuous 476 | ).fill( 477 | enumValue == onValue 478 | ? onColor 479 | : ( 480 | hovering 481 | ? hoverColor 482 | : (offColor ?? color.opacity(colorScheme == .dark ? 0.5 : 0.8)) 483 | ) 484 | ) 485 | ) 486 | .brightness(hovering ? 0.05 : 0.0) 487 | .contrast(hovering ? 1.01 : 1.0) 488 | .scaleEffect(hovering ? 1.05 : 1.00) 489 | .contentShape(Rectangle()) 490 | .onHover(perform: { hover in 491 | guard isEnabled else { return } 492 | guard enumValue != onValue else { 493 | hovering = false 494 | return 495 | } 496 | withAnimation(.fastTransition) { 497 | hovering = hover 498 | } 499 | }) 500 | .onChange(of: enumValue) { v in 501 | if hovering, v == onValue { 502 | hovering = false 503 | } 504 | } 505 | .onChange(of: isEnabled) { e in 506 | if !e { 507 | withAnimation(.fastTransition) { 508 | hovering = false 509 | } 510 | } 511 | } 512 | .opacity(isEnabled ? 1 : 0.6) 513 | } 514 | 515 | @Environment(\.colors) var colors 516 | @Environment(\.colorScheme) var colorScheme 517 | 518 | @State var color = Color.primary.opacity(0.15) 519 | @State var hovering = false 520 | @State var onColor: Color = .primary 521 | @State var offColor: Color? = nil 522 | @State var onTextColor: Color? = nil 523 | @State var offTextColor = Color.secondary 524 | @State var horizontalPadding: CGFloat = 8 525 | @State var verticalPadding: CGFloat = 4 526 | @State var brightness = 0.0 527 | @State var scale: CGFloat = 1 528 | @State var radius: CGFloat 529 | @State var hoverColor: Color 530 | @State var hoverTextColor: Color 531 | @Binding var enumValue: T 532 | @State var onValue: T 533 | } 534 | 535 | // MARK: - FlatButton 536 | 537 | public struct FlatButton: ButtonStyle { 538 | public init( 539 | color: Color? = nil, 540 | textColor: Color? = nil, 541 | hoverColor: Color? = nil, 542 | colorBinding: Binding? = nil, 543 | textColorBinding: Binding? = nil, 544 | hoverColorBinding: Binding? = nil, 545 | width: CGFloat? = nil, 546 | height: CGFloat? = nil, 547 | circle: Bool = false, 548 | radius: CGFloat = 8, 549 | pressedBinding: Binding? = nil, 550 | horizontalPadding: CGFloat = 8, 551 | verticalPadding: CGFloat = 4, 552 | shadowSize: CGFloat = 0, 553 | stretch: Bool = false, 554 | hoverColorEffects: Bool = true, 555 | hoverScaleEffects: Bool = true 556 | ) { 557 | _color = colorBinding ?? .constant(color ?? Color.primary) 558 | _textColor = textColorBinding ?? .constant(textColor) 559 | _hoverColor = hoverColorBinding ?? .constant(hoverColor) 560 | _width = .constant(width) 561 | _height = .constant(height) 562 | _circle = .constant(circle) 563 | _radius = .constant(radius) 564 | _pressed = pressedBinding ?? .constant(false) 565 | _horizontalPadding = horizontalPadding.state 566 | _verticalPadding = verticalPadding.state 567 | _shadowSize = shadowSize.state 568 | _stretch = State(initialValue: stretch) 569 | _hoverColorEffects = State(initialValue: hoverColorEffects) 570 | _hoverScaleEffects = State(initialValue: hoverScaleEffects) 571 | } 572 | 573 | @Environment(\.isEnabled) public var isEnabled 574 | 575 | public func makeBody(configuration: Configuration) -> some View { 576 | configuration 577 | .label 578 | .foregroundColor(textColor ?? colors.inverted) 579 | .padding(.vertical, verticalPadding) 580 | .padding(.horizontal, horizontalPadding) 581 | .frame( 582 | minWidth: width, 583 | idealWidth: width, 584 | maxWidth: stretch ? .infinity : nil, 585 | minHeight: height, 586 | idealHeight: height, 587 | alignment: .center 588 | ) 589 | .background( 590 | bg.colorMultiply(configuration.isPressed || pressed ? pressedColor : .white) 591 | .shadow(radius: shadowSize, y: shadowSize * 0.66) 592 | ) 593 | .if(hoverColorEffects) { 594 | $0.brightness(hovering ? 0.05 : 0.0) 595 | .contrast(hovering ? 1.01 : 1.0) 596 | } 597 | .if(hoverScaleEffects) { 598 | $0.scaleEffect( 599 | configuration.isPressed || pressed 600 | ? 1.02 601 | : (hovering ? 1.05 : 1.00) 602 | ) 603 | } 604 | .onAppear { 605 | pressedColor = hoverColor?.blended(withFraction: 0.5, of: .white) ?? color.blended(withFraction: 0.2, of: colors.accent) 606 | } 607 | .onHover(perform: { hover in 608 | guard isEnabled else { return } 609 | withAnimation(.fastTransition) { 610 | hovering = hover 611 | } 612 | }) 613 | .onChange(of: isEnabled) { e in 614 | if !e { 615 | withAnimation(.fastTransition) { 616 | hovering = false 617 | } 618 | } 619 | } 620 | .opacity(isEnabled ? 1 : 0.6) 621 | } 622 | 623 | @Environment(\.colors) var colors 624 | 625 | @Binding var color: Color 626 | @Binding var textColor: Color? 627 | @State var colorMultiply: Color = .white 628 | @State var scale: CGFloat = 1.0 629 | @Binding var hoverColor: Color? 630 | @State var pressedColor: Color = .white 631 | @Binding var width: CGFloat? 632 | @Binding var height: CGFloat? 633 | @Binding var circle: Bool 634 | @Binding var radius: CGFloat 635 | @Binding var pressed: Bool 636 | @State var horizontalPadding: CGFloat = 8 637 | @State var verticalPadding: CGFloat = 4 638 | @State var shadowSize: CGFloat = 0 639 | @State var stretch = false 640 | @State var hovering = false 641 | @State var hoverColorEffects = true 642 | @State var hoverScaleEffects = true 643 | 644 | var bg: some View { 645 | circle 646 | ? 647 | AnyView( 648 | Circle().fill(color) 649 | .frame( 650 | minWidth: width, 651 | idealWidth: width, 652 | maxWidth: stretch ? .infinity : nil, 653 | minHeight: height, 654 | idealHeight: height, 655 | alignment: .center 656 | ) 657 | ) 658 | : AnyView( 659 | RoundedRectangle( 660 | cornerRadius: radius, 661 | style: .continuous 662 | ).fill(color).frame( 663 | minWidth: width, 664 | idealWidth: width, 665 | maxWidth: stretch ? .infinity : nil, 666 | minHeight: height, 667 | idealHeight: height, 668 | alignment: .center 669 | ) 670 | ) 671 | } 672 | } 673 | 674 | // MARK: - PaddedTextFieldStyle 675 | 676 | public struct PaddedTextFieldStyle: TextFieldStyle { 677 | public init( 678 | size: CGFloat = 13, 679 | verticalPadding: CGFloat = 4, 680 | horizontalPadding: CGFloat = 8, 681 | shake: Binding? = nil 682 | ) { 683 | _size = State(initialValue: size) 684 | _shake = shake ?? .constant(false) 685 | } 686 | 687 | public func _body(configuration: TextField) -> some View { 688 | configuration 689 | .textFieldStyle(.plain) 690 | .font(.system(size: size, weight: .medium)) 691 | .padding(.vertical, verticalPadding) 692 | .padding(.horizontal, horizontalPadding) 693 | .background( 694 | RoundedRectangle(cornerRadius: 6, style: .continuous) 695 | .fill(Color.primary.opacity(0.1)) 696 | .shadow(color: Colors.blackMauve.opacity(0.1), radius: 3, x: 0, y: 2) 697 | ) 698 | .modifier(ShakeEffect(shakes: shake ? 2 : 0)) 699 | .animation(Animation.default.repeatCount(2).speed(1.5), value: shake) 700 | } 701 | 702 | @Environment(\.colorScheme) var colorScheme 703 | @State var size: CGFloat = 13 704 | @State var verticalPadding: CGFloat = 4 705 | @State var horizontalPadding: CGFloat = 8 706 | @Binding var shake: Bool 707 | } 708 | 709 | // MARK: - ShakeEffect 710 | 711 | public struct ShakeEffect: GeometryEffect { 712 | public init(shakes: Int) { 713 | position = CGFloat(shakes) 714 | } 715 | 716 | public var position: CGFloat 717 | 718 | public var animatableData: CGFloat { 719 | get { position } 720 | set { position = newValue } 721 | } 722 | 723 | public func effectValue(size: CGSize) -> ProjectionTransform { 724 | ProjectionTransform(CGAffineTransform(translationX: -5 * sin(position * 2 * .pi), y: 0)) 725 | } 726 | } 727 | -------------------------------------------------------------------------------- /Sources/Lowtech/Components.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import SwiftUI 4 | 5 | // MARK: - Semaphore 6 | 7 | public struct Semaphore: View { 8 | public init() {} 9 | 10 | @State public var xVisible = false 11 | 12 | public var body: some View { 13 | HStack { 14 | Button( 15 | action: { LowtechAppDelegate.instance.hidePopover() }, 16 | label: { 17 | ZStack(alignment: .center) { 18 | Circle().fill(Color.red).frame(width: 14, height: 14, alignment: .center) 19 | Image(systemName: "xmark").font(.system(size: 8, weight: .bold)) 20 | .foregroundColor(.black.opacity(0.8)) 21 | .opacity(xVisible ? 1 : 0) 22 | } 23 | } 24 | ).buttonStyle(.plain) 25 | .onHover { hover in withAnimation(.easeOut(duration: 0.15)) { xVisible = hover }} 26 | Circle().fill(Color.gray.opacity(0.3)).frame(width: 14, height: 14, alignment: .center) 27 | Circle().fill(Color.gray.opacity(0.3)).frame(width: 14, height: 14, alignment: .center) 28 | } 29 | .padding(.leading, -8) 30 | .padding(.top, -8) 31 | .focusable(false) 32 | } 33 | } 34 | 35 | // MARK: - VScrollView 36 | 37 | public struct VScrollView: View where Content: View { 38 | public init(@ViewBuilder content: () -> Content) { self.content = content() } 39 | 40 | @ViewBuilder public let content: Content 41 | 42 | public var body: some View { 43 | GeometryReader { geometry in 44 | ScrollView(.vertical, showsIndicators: false) { 45 | content 46 | .frame(width: geometry.size.width) 47 | .frame(minHeight: geometry.size.height) 48 | } 49 | } 50 | } 51 | } 52 | 53 | // MARK: - HorizontalScrollViewOffsetPreferenceKey 54 | 55 | struct HorizontalScrollViewOffsetPreferenceKey: PreferenceKey { 56 | static var defaultValue: CGFloat = 0.0 57 | 58 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 59 | value = nextValue() 60 | } 61 | } 62 | 63 | // MARK: - HorizontalScrollViewFullWidthPreferenceKey 64 | 65 | struct HorizontalScrollViewFullWidthPreferenceKey: PreferenceKey { 66 | static var defaultValue: CGFloat = 0.0 67 | 68 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 69 | value = nextValue() 70 | } 71 | } 72 | 73 | // MARK: - HorizontalScrollViewWidthPreferenceKey 74 | 75 | struct HorizontalScrollViewWidthPreferenceKey: PreferenceKey { 76 | static var defaultValue: CGFloat = 0.0 77 | 78 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 79 | value = nextValue() 80 | } 81 | } 82 | 83 | // MARK: - HScrollView 84 | 85 | public struct HScrollView: View { 86 | public init( 87 | @ViewBuilder content: () -> Content, 88 | gradientOpacity: CGFloat = 1.0, 89 | scrollViewFullWidth: CGFloat = 0, 90 | scrollViewWidth: CGFloat = 0, 91 | scrollViewOffset: CGFloat = 0, 92 | gradientColor: Color? = nil, 93 | gradientRadius: CGFloat = 0 94 | ) { 95 | self.content = content() 96 | _gradientOpacity = gradientOpacity.state 97 | _scrollViewFullWidth = scrollViewFullWidth.state 98 | _scrollViewWidth = scrollViewWidth.state 99 | _scrollViewOffset = scrollViewOffset.state 100 | _gradientColor = gradientColor.state 101 | _gradientRadius = gradientRadius.state 102 | } 103 | 104 | @ViewBuilder public let content: Content 105 | 106 | public var body: some View { 107 | GeometryReader { geometry in 108 | ScrollView(.horizontal, showsIndicators: false) { 109 | ZStack { 110 | content 111 | .frame(height: geometry.size.height) 112 | .frame(minWidth: geometry.size.width) 113 | GeometryReader { proxy in 114 | let offset = proxy.frame(in: .named("scroll")).minX 115 | Color.clear 116 | .preference(key: HorizontalScrollViewFullWidthPreferenceKey.self, value: proxy.size.width) 117 | .preference(key: HorizontalScrollViewOffsetPreferenceKey.self, value: offset) 118 | } 119 | Color.clear 120 | .preference(key: HorizontalScrollViewWidthPreferenceKey.self, value: geometry.size.width) 121 | } 122 | } 123 | .coordinateSpace(name: "scroll") 124 | .onPreferenceChange(HorizontalScrollViewOffsetPreferenceKey.self) { value in 125 | scrollViewOffset = value 126 | computeGradientOpacity() 127 | } 128 | .onPreferenceChange(HorizontalScrollViewFullWidthPreferenceKey.self) { value in 129 | scrollViewFullWidth = value 130 | computeGradientOpacity() 131 | } 132 | .onPreferenceChange(HorizontalScrollViewWidthPreferenceKey.self) { value in 133 | scrollViewWidth = value 134 | computeGradientOpacity() 135 | } 136 | 137 | if let gradientColor { 138 | LinearGradient( 139 | colors: [gradientColor, gradientColor.opacity(0)], 140 | startPoint: .trailing, 141 | endPoint: UnitPoint(x: UnitPoint.trailing.x - 0.2, y: UnitPoint.center.y) 142 | ) 143 | .if(gradientRadius > 0) { $0.clipShape(RoundedRectangle(cornerRadius: gradientRadius, style: .continuous)) } 144 | .frame(maxWidth: .infinity, maxHeight: .infinity) 145 | .allowsHitTesting(false) 146 | .opacity(gradientOpacity) 147 | } 148 | } 149 | } 150 | 151 | @State var gradientOpacity: CGFloat = 1.0 152 | @State var scrollViewFullWidth: CGFloat = 0 153 | @State var scrollViewWidth: CGFloat = 0 154 | @State var scrollViewOffset: CGFloat = 0 155 | @State var gradientColor: Color? = nil 156 | @State var gradientRadius: CGFloat = 0 157 | 158 | func computeGradientOpacity() { 159 | gradientOpacity = cap((scrollViewFullWidth + scrollViewOffset) - scrollViewWidth, minVal: 0, maxVal: 40) / 40.0 160 | } 161 | } 162 | 163 | // MARK: - HomekitSlider 164 | 165 | public struct HomekitSlider: View { 166 | public init( 167 | percentage: Binding, 168 | sliderWidth: CGFloat = 80, 169 | sliderHeight: CGFloat = 160, 170 | imageSize: CGFloat = 22, 171 | radius: CGFloat = 24, 172 | image: String? = nil, 173 | color: Color? = nil, 174 | hoverColor: Color? = nil, 175 | backgroundColor: Color = .black.opacity(0.1) 176 | ) { 177 | _percentage = percentage 178 | _sliderWidth = sliderWidth.state 179 | _sliderHeight = sliderHeight.state 180 | _imageSize = imageSize.state 181 | _radius = radius.state 182 | _image = image.state 183 | _color = color.state 184 | _hoverColor = hoverColor.state 185 | _backgroundColor = backgroundColor.state 186 | } 187 | 188 | public var body: some View { 189 | GeometryReader { geometry in 190 | ZStack(alignment: .bottom) { 191 | Rectangle() 192 | .foregroundColor(Color.black.opacity(0.1)) 193 | Rectangle() 194 | .foregroundColor(color ?? colors.accent) 195 | .colorMultiply(colorMultiply) 196 | .frame(height: geometry.size.height * percentage.cg) 197 | 198 | if let image { 199 | Image(systemName: image) 200 | .resizable() 201 | .frame(width: imageSize, height: imageSize, alignment: .bottom) 202 | .font(.body.weight(.medium)) 203 | .frame(width: sliderWidth, height: sliderHeight) 204 | .foregroundColor(Color.white.opacity(0.4)) 205 | .blendMode(.exclusion) 206 | } 207 | } 208 | .frame(width: sliderWidth, height: sliderHeight) 209 | .cornerRadius(radius) 210 | .gesture( 211 | DragGesture(minimumDistance: 0) 212 | .onChanged { value in 213 | if scale == 1 { 214 | HomekitSlider.sliderTouched = true 215 | withAnimation(.interactiveSpring()) { 216 | colorMultiply = hoverColor ?? colors.accent 217 | scale = 1.05 218 | } 219 | } 220 | percentage = cap(Float((geometry.size.height - value.location.y) / geometry.size.height), minVal: 0, maxVal: 1) 221 | }.onEnded { value in 222 | HomekitSlider.sliderTouched = false 223 | withAnimation(.spring()) { 224 | scale = 1.0 225 | colorMultiply = .white 226 | } 227 | percentage = cap(Float((geometry.size.height - value.location.y) / geometry.size.height), minVal: 0, maxVal: 1) 228 | } 229 | ) 230 | .animation(.easeOut(duration: 0.1), value: percentage) 231 | .scaleEffect(scale) 232 | #if os(macOS) 233 | .onHover { hovering in 234 | if hovering { 235 | trackScrollWheel() 236 | } else { 237 | HomekitSlider.sliderTouched = false 238 | withAnimation(.spring()) { 239 | scale = 1.0 240 | colorMultiply = .white 241 | } 242 | percentage = cap(percentage, minVal: 0, maxVal: 1) 243 | subs.forEach { $0.cancel() } 244 | subs.removeAll() 245 | } 246 | } 247 | #endif 248 | 249 | }.frame(width: sliderWidth, height: sliderHeight) 250 | } 251 | 252 | @Atomic static var sliderTouched = false 253 | 254 | @Environment(\.colorScheme) var colorScheme 255 | @Environment(\.colors) var colors 256 | 257 | @Binding var percentage: Float 258 | @State var sliderWidth: CGFloat = 80 259 | @State var sliderHeight: CGFloat = 160 260 | @State var imageSize: CGFloat = 22 261 | @State var radius: CGFloat = 24 262 | 263 | @State var image: String? = nil 264 | @State var color: Color? = nil 265 | @State var hoverColor: Color? = nil 266 | @State var backgroundColor: Color = .black.opacity(0.1) 267 | 268 | @State var colorMultiply: Color = .white 269 | @State var scale: CGFloat = 1.0 270 | @State var subs = Set() 271 | 272 | #if os(macOS) 273 | func trackScrollWheel() { 274 | let pub = NSApp.publisher(for: \.currentEvent) 275 | pub 276 | .filter { event in event?.type == .scrollWheel } 277 | .throttle( 278 | for: .milliseconds(20), 279 | scheduler: DispatchQueue.main, 280 | latest: true 281 | ) 282 | .sink { event in 283 | guard let event, event.deltaX == 0, event.scrollingDeltaY != 0 else { 284 | HomekitSlider.sliderTouched = false 285 | withAnimation(.spring()) { 286 | scale = 1.0 287 | colorMultiply = .white 288 | } 289 | percentage = cap(percentage, minVal: 0, maxVal: 1) 290 | return 291 | } 292 | if scale == 1 { 293 | HomekitSlider.sliderTouched = true 294 | withAnimation(.interactiveSpring()) { 295 | colorMultiply = hoverColor ?? colors.accent 296 | scale = 1.05 297 | } 298 | } 299 | let delta = Float(event.scrollingDeltaY) * (event.isDirectionInvertedFromDevice ? 1 : -1) 300 | percentage = cap(percentage - (delta / 100), minVal: 0, maxVal: 1) 301 | } 302 | .store(in: &subs) 303 | } 304 | #endif 305 | } 306 | 307 | // MARK: - BigSurSlider 308 | 309 | public struct BigSurSlider: View { 310 | public init( 311 | percentage: Binding, 312 | sliderWidth: CGFloat = 200, 313 | sliderHeight: CGFloat = 22, 314 | image: String? = nil, 315 | imageBinding: Binding? = nil, 316 | color: Color? = nil, 317 | colorBinding: Binding? = nil, 318 | backgroundColor: Color = .black.opacity(0.1), 319 | backgroundColorBinding: Binding? = nil, 320 | knobColor: Color? = nil, 321 | knobColorBinding: Binding? = nil, 322 | knobTextColor: Color? = nil, 323 | knobTextColorBinding: Binding? = nil, 324 | imgColor: Color? = nil, 325 | showValue: Binding? = nil, 326 | acceptsMouseEvents: Binding? = nil, 327 | enableText: String? = nil, 328 | mark: Binding? = nil, 329 | enable: (() -> Void)? = nil 330 | ) { 331 | _knobColor = .constant(knobColor) 332 | _knobTextColor = .constant(knobTextColor) 333 | 334 | _percentage = percentage 335 | _sliderWidth = sliderWidth.state 336 | _sliderHeight = sliderHeight.state 337 | _image = imageBinding ?? .constant(image) 338 | _color = colorBinding ?? .constant(color) 339 | _showValue = showValue ?? .constant(false) 340 | _backgroundColor = backgroundColorBinding ?? .constant(backgroundColor) 341 | _acceptsMouseEvents = acceptsMouseEvents ?? .constant(true) 342 | _enableText = State(initialValue: enableText) 343 | _mark = mark ?? .constant(0) 344 | _imgColor = .constant(.black) 345 | 346 | _knobColor = knobColorBinding ?? colorBinding ?? .constant(knobColor ?? Colors.saffron) 347 | _knobTextColor = knobTextColorBinding ?? .constant(knobTextColor ?? ((color ?? Colors.saffron).textColor(colors: Colors.light))) 348 | _imgColor = .constant(imgColor ?? color?.textColor(colors: Colors.light) ?? Color.black) 349 | self.enable = enable 350 | } 351 | 352 | @Environment(\.isEnabled) public var isEnabled 353 | 354 | public var body: some View { 355 | GeometryReader { geometry in 356 | let w = geometry.size.width - sliderHeight 357 | let cgPercentage = cap(percentage, minVal: 0, maxVal: 1).cg 358 | 359 | ZStack(alignment: .leading) { 360 | Rectangle() 361 | .foregroundColor(backgroundColor) 362 | ZStack(alignment: .leading) { 363 | Rectangle() 364 | .foregroundColor(color ?? colors.accent) 365 | .frame(width: cgPercentage == 1 ? geometry.size.width : w * cgPercentage + sliderHeight / 2) 366 | if let image { 367 | Image(systemName: image) 368 | .resizable() 369 | .frame(width: 12, height: 12, alignment: .center) 370 | .font(.body.weight(.heavy)) 371 | .frame(width: sliderHeight - 7, height: sliderHeight - 7) 372 | .foregroundColor(imgColor.opacity(imgColor.isLight ? 0.9 : 0.6)) 373 | .offset(x: 3, y: 0) 374 | } 375 | ZStack { 376 | Circle() 377 | .foregroundColor(knobColor) 378 | .shadow(color: Colors.blackMauve.opacity(percentage > 0.3 ? 0.3 : percentage.d), radius: 5, x: -1, y: 0) 379 | .frame(width: sliderHeight, height: sliderHeight, alignment: .trailing) 380 | .brightness(env.draggingSlider && hovering ? -0.2 : 0) 381 | if showValue { 382 | Text((percentage * 100).str(decimals: 0)) 383 | .foregroundColor(knobTextColor) 384 | .font(.system(size: 8, weight: .medium, design: .monospaced)) 385 | .allowsHitTesting(false) 386 | } 387 | }.offset( 388 | x: cgPercentage * w, 389 | y: 0 390 | ) 391 | if mark > 0 { 392 | RoundedRectangle(cornerRadius: 1, style: .continuous) 393 | .fill(Color.red.opacity(0.7)) 394 | .frame(width: 3, height: sliderHeight - 5, alignment: .center) 395 | .offset( 396 | x: cap(mark, minVal: 0, maxVal: 1).cg * w, 397 | y: 0 398 | ).animation(.jumpySpring, value: mark) 399 | } 400 | } 401 | .contrast(!isEnabled ? 0.4 : 1.0) 402 | .saturation(!isEnabled ? 0.4 : 1.0) 403 | 404 | if !isEnabled, hovering, let enableText, let enable { 405 | SwiftUI.Button(enableText) { 406 | enable() 407 | } 408 | .buttonStyle(FlatButton( 409 | color: Colors.red.opacity(0.7), 410 | textColor: .white, 411 | horizontalPadding: 6, 412 | verticalPadding: 2 413 | )) 414 | .font(.system(size: 10, weight: .medium, design: .rounded)) 415 | .transition(.scale.animation(.fastSpring)) 416 | .frame(maxWidth: .infinity, alignment: .center) 417 | } 418 | } 419 | .frame(width: sliderWidth, height: sliderHeight) 420 | .cornerRadius(20) 421 | .gesture( 422 | DragGesture(minimumDistance: 0) 423 | .onChanged { value in 424 | guard acceptsMouseEvents, isEnabled else { return } 425 | if !env.draggingSlider { 426 | if draggingSliderSetter == nil { 427 | draggingSliderSetter = mainAsyncAfter(ms: 200) { 428 | env.draggingSlider = true 429 | } 430 | } else { 431 | draggingSliderSetter = nil 432 | env.draggingSlider = true 433 | } 434 | } 435 | 436 | percentage = cap(Float(value.location.x / geometry.size.width), minVal: 0, maxVal: 1) 437 | } 438 | .onEnded { value in 439 | guard acceptsMouseEvents, isEnabled else { return } 440 | draggingSliderSetter = nil 441 | percentage = cap(Float(value.location.x / geometry.size.width), minVal: 0, maxVal: 1) 442 | env.draggingSlider = false 443 | } 444 | ) 445 | #if os(macOS) 446 | .onHover { hov in 447 | hovering = hov 448 | guard acceptsMouseEvents, isEnabled else { return } 449 | 450 | if hovering { 451 | lastCursorPosition = NSEvent.mouseLocation 452 | hoveringSliderSetter = mainAsyncAfter(ms: 200) { 453 | guard lastCursorPosition != NSEvent.mouseLocation else { return } 454 | env.hoveringSlider = hovering 455 | } 456 | trackScrollWheel() 457 | } else { 458 | hoveringSliderSetter = nil 459 | env.hoveringSlider = false 460 | } 461 | } 462 | #endif 463 | } 464 | .frame(width: sliderWidth, height: sliderHeight) 465 | } 466 | 467 | @Environment(\.colorScheme) var colorScheme 468 | @Environment(\.colors) var colors 469 | @EnvironmentObject var env: EnvState 470 | 471 | @Binding var percentage: Float 472 | @State var sliderWidth: CGFloat = 200 473 | @State var sliderHeight: CGFloat = 22 474 | @Binding var image: String? 475 | @Binding var color: Color? 476 | @Binding var backgroundColor: Color 477 | @Binding var knobColor: Color? 478 | @Binding var knobTextColor: Color? 479 | @Binding var imgColor: Color 480 | @Binding var showValue: Bool 481 | 482 | @State var scrollWheelListener: Cancellable? 483 | 484 | @State var hovering = false 485 | @State var enableText: String? = nil 486 | @State var lastCursorPosition = NSEvent.mouseLocation 487 | @Binding var acceptsMouseEvents: Bool 488 | @Binding var mark: Float 489 | 490 | var enable: (() -> Void)? 491 | 492 | #if os(macOS) 493 | func trackScrollWheel() { 494 | guard scrollWheelListener == nil else { return } 495 | scrollWheelListener = NSApp.publisher(for: \.currentEvent) 496 | .filter { event in event?.type == .scrollWheel } 497 | .throttle(for: .milliseconds(20), scheduler: DispatchQueue.main, latest: true) 498 | .sink { event in 499 | guard hovering, env.hoveringSlider, let event, event.momentumPhase.rawValue == 0 else { 500 | if let event, event.scrollingDeltaX + event.scrollingDeltaY == 0, event.phase.rawValue == 0, 501 | env.draggingSlider 502 | { 503 | env.draggingSlider = false 504 | } 505 | return 506 | } 507 | 508 | let delta = Float(event.scrollingDeltaX) * (event.isDirectionInvertedFromDevice ? -1 : 1) 509 | + Float(event.scrollingDeltaY) * (event.isDirectionInvertedFromDevice ? 1 : -1) 510 | 511 | switch event.phase { 512 | case .changed, .began, .mayBegin: 513 | if !env.draggingSlider { 514 | env.draggingSlider = true 515 | } 516 | case .ended, .cancelled, .stationary: 517 | if env.draggingSlider { 518 | env.draggingSlider = false 519 | } 520 | default: 521 | if delta == 0, env.draggingSlider { 522 | env.draggingSlider = false 523 | } 524 | } 525 | percentage = cap(percentage - (delta / 100), minVal: 0, maxVal: 1) 526 | } 527 | } 528 | #endif 529 | } 530 | 531 | extension NSEvent.Phase { 532 | var str: String { 533 | switch self { 534 | case .mayBegin: "mayBegin" 535 | case .began: "began" 536 | case .changed: "changed" 537 | case .stationary: "stationary" 538 | case .cancelled: "cancelled" 539 | case .ended: "ended" 540 | default: 541 | "phase(\(rawValue))" 542 | } 543 | } 544 | } 545 | 546 | var hoveringSliderSetter: DispatchWorkItem? { 547 | didSet { oldValue?.cancel() } 548 | } 549 | 550 | var draggingSliderSetter: DispatchWorkItem? { 551 | didSet { oldValue?.cancel() } 552 | } 553 | 554 | // MARK: - UpDownButtons 555 | 556 | public struct UpDownButtons: View { 557 | public init( 558 | radius: CGFloat = 24, 559 | size: CGFloat = 80, 560 | color: Color? = nil, 561 | textColor: Color = .black, 562 | onPress: @escaping (ButtonDirection) -> Void 563 | ) { 564 | _radius = radius.state 565 | _size = size.state 566 | _color = color.state 567 | _textColor = textColor.state 568 | _onPress = st(onPress) 569 | } 570 | 571 | public enum ButtonDirection { 572 | case down 573 | case up 574 | } 575 | 576 | public var body: some View { 577 | ZStack { 578 | RoundedRectangle(cornerRadius: radius, style: .continuous) 579 | .fill(color ?? colors.accent) 580 | .frame(width: size, height: size * 2, alignment: .center) 581 | .zIndex(0) 582 | VStack(spacing: 0) { 583 | Button( 584 | action: { onPress?(.up) }, 585 | label: { Image(systemName: "plus").font(.system(size: size / 3, weight: .black)) } 586 | ) 587 | .buttonStyle(FlatButton( 588 | color: color ?? colors.accent, 589 | textColor: textColor, 590 | width: size, 591 | height: size, 592 | radius: radius 593 | )) 594 | .zIndex(upZ) 595 | .onHover(perform: { hover in 596 | if hover { 597 | upZ = 2 598 | downZ = 1 599 | } 600 | }) 601 | Button( 602 | action: { onPress?(.down) }, 603 | label: { Image(systemName: "minus").font(.system(size: size / 3, weight: .black)) } 604 | ) 605 | .buttonStyle(FlatButton( 606 | color: color ?? colors.accent, 607 | textColor: textColor, 608 | width: size, 609 | height: size, 610 | radius: radius 611 | )) 612 | .zIndex(downZ) 613 | .onHover(perform: { hover in 614 | if hover { 615 | upZ = 1 616 | downZ = 2 617 | } 618 | }) 619 | } 620 | } 621 | } 622 | 623 | @Environment(\.colors) var colors 624 | 625 | @State var radius: CGFloat = 24 626 | @State var size: CGFloat = 80 627 | @State var color: Color? = nil 628 | @State var textColor: Color = .black 629 | @State var onPress: ((ButtonDirection) -> Void)? = nil 630 | 631 | @State var upZ: Double = 1 632 | @State var downZ: Double = 2 633 | } 634 | 635 | // MARK: - TextInputView 636 | 637 | public struct TextInputView: View { 638 | public init( 639 | label: String, 640 | placeholder: String, 641 | data: Binding, 642 | size: CGFloat = 13 643 | ) { 644 | _label = label.state 645 | _placeholder = placeholder.state 646 | _data = data 647 | _size = size.state 648 | } 649 | 650 | public var body: some View { 651 | VStack(alignment: .leading, spacing: 4) { 652 | Text(label).font(.system(size: size, weight: .semibold)) 653 | TextField(placeholder, text: $data) 654 | .textFieldStyle(PaddedTextFieldStyle()) 655 | } 656 | } 657 | 658 | @State var label: String 659 | @State var placeholder: String 660 | @Binding var data: String 661 | 662 | @State var size: CGFloat = 13 663 | } 664 | 665 | // MARK: - ValueInputView 666 | 667 | public struct ValueInputView: View { 668 | public init( 669 | label: String, 670 | placeholder: String, 671 | data: Binding, 672 | size: CGFloat = 13, 673 | formatter: Formatter 674 | ) { 675 | _label = label.state 676 | _placeholder = placeholder.state 677 | _data = data 678 | _size = size.state 679 | _formatter = st(formatter) 680 | } 681 | 682 | public var body: some View { 683 | VStack(alignment: .leading, spacing: 4) { 684 | Text(label).font(.system(size: size, weight: .semibold)) 685 | TextField(placeholder, value: $data, formatter: formatter) 686 | .textFieldStyle(PaddedTextFieldStyle()) 687 | } 688 | } 689 | 690 | @State var label: String 691 | @State var placeholder: String 692 | @Binding var data: T 693 | @State var formatter: Formatter 694 | 695 | @State var size: CGFloat = 13 696 | } 697 | --------------------------------------------------------------------------------