diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5dd8fd0..52abb44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,46 +1,40 @@ -name: 'Tests' +# .github/workflows/main.yml +name: 'Build & Tests' -on: +on: push: branches: [ master ] pull_request: branches: [ master ] # schedule: # - cron: '5 5 * * 5' - + jobs: - unit-testing-iOS: - runs-on: macos-latest + + macOS-build: + runs-on: macos-14 steps: - name: Checkout the code - uses: actions/checkout@v2 - - name: Show xcodebuild version - run: xcodebuild -version - - name: Show xcode embedded SDKs - run: xcodebuild -showsdks - - name: Show buildable schemes - run: xcodebuild -list - - uses: mxcl/xcodebuild@v1.9.2 + uses: actions/checkout@v4 + - name: Select Xcode 15.4 + run: sudo xcode-select -s "/Applications/Xcode_15.4.app" + - uses: mxcl/xcodebuild@v3 with: - platform: iOS + platform: macOS scheme: 'PerseusDarkMode' - action: test + action: build code-coverage: true verbosity: xcpretty upload-logs: always - - unit-testing-macOS: - runs-on: macos-latest + + macOS-test: + runs-on: macos-14 steps: - name: Checkout the code - uses: actions/checkout@v2 - - name: Show xcodebuild version - run: xcodebuild -version - - name: Show xcode embedded SDKs - run: xcodebuild -showsdks - - name: Show buildable schemes - run: xcodebuild -list - - uses: mxcl/xcodebuild@v1.9.2 + uses: actions/checkout@v4 + - name: Select Xcode 15.4 + run: sudo xcode-select -s "/Applications/Xcode_15.4.app" + - uses: mxcl/xcodebuild@v3 with: platform: macOS scheme: 'PerseusDarkMode' @@ -48,4 +42,35 @@ jobs: code-coverage: true verbosity: xcpretty upload-logs: always - + + iOS-build: + runs-on: macos-14 + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - name: Select Xcode 15.4 + run: sudo xcode-select -s "/Applications/Xcode_15.4.app" + - uses: mxcl/xcodebuild@v3 + with: + platform: iOS + scheme: 'PerseusDarkMode' + action: build + code-coverage: true + verbosity: xcpretty + upload-logs: always + + iOS-test: + runs-on: macos-14 + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - name: Select Xcode 15.4 + run: sudo xcode-select -s "/Applications/Xcode_15.4.app" + - uses: mxcl/xcodebuild@v3 + with: + platform: iOS + scheme: 'PerseusDarkMode' + action: test + code-coverage: true + verbosity: xcpretty + upload-logs: always diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 0000000..85575f3 --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,17 @@ +# .github/workflows/swiftlint.yml +name: Style + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + SwiftLint: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: cirruslabs/swiftlint-action@v1 + with: + version: latest diff --git a/.gitignore b/.gitignore index f6dcfcc..c8a1b78 100644 --- a/.gitignore +++ b/.gitignore @@ -5,14 +5,11 @@ ## User settings xcuserdata/ -## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) -*.xcscmblueprint -*.xccheckout - -## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +## Build generated build/ DerivedData/ -*.moved-aside + +## Various settings *.pbxuser !default.pbxuser *.mode1v3 @@ -21,10 +18,14 @@ DerivedData/ !default.mode2v3 *.perspectivev3 !default.perspectivev3 +xcuserdata/ ## Other -.DS_Store -swiftlint.txt +*.moved-aside +*.xccheckout +*.xcscmblueprint +*.DS_Store +SwiftLintOutput ## Obj-C/Swift specific *.hmap @@ -34,61 +35,16 @@ swiftlint.txt *.dSYM.zip *.dSYM -## Playgrounds -timeline.xctimeline -playground.xcworkspace - # Swift Package Manager # # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins Package.resolved -.xcodeproj +*.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project .swiftpm .build/ - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# Accio dependency management -Dependencies/ -.accio/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output - -# Code Injection -# -# After new code Injection tools there's a generated folder /iOSInjectionProject -# https://github.com/johnno1962/injectionforxcode - -iOSInjectionProject/ diff --git a/.swiftlint.yml b/.swiftlint.yml index e486b73..7694456 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -50,4 +50,4 @@ function_parameter_count: large_tuple: warning: 4 - error: 5 + error: 5 \ No newline at end of file diff --git a/APPROBATION.md b/APPROBATION.md new file mode 100644 index 0000000..f9d608c --- /dev/null +++ b/APPROBATION.md @@ -0,0 +1,53 @@ +# Approbation Matrix / PerseusDarkMode 2.0.0 + +> NOTE: To catch all log messages Mac Console should be started first then after a little while the logged app. + +> Compilation: macOS Monterey 12.7.6 / Xcode 14.2 + +## macOS approbation result + +> The approbation macOS app for PerseusDarkMode 2.0.0 is [Arkenstone](https://github.com/perseusrealdeal/Arkenstone). + +| macOS | Version | Result | Details | +| ----------- | ------- | :-----: | ------- | +| High Sierra | 10.13 | ok | - | +| Mojave | 10.14 | ok | - | +| Catalina | 10.15 | ok | - | +| Big Sur | 11.7 | ok | - | +| Monterey | 12.7 | ok | - | +| Ventura | 13.7 | ok | - | +| Sonoma | 14.7 | ok | - | +| Sequoia | 15.3 | ok | - | + +## iOS approbation result + +> The approbation iOS app for PerseusDarkMode 2.0.0 is [The One Ring](https://github.com/perseusrealdeal/TheOneRing). + +| Device | Simulator | OS Version | Result | Details | +| --------------- | :-------: | ---------- | :-----: | ------- | +| iPad Air | no | 12.5.7 | ok | - | +| iPhone SE (3rd) | yes | 16.2 | ok | - | + +## A3 environment + +### List of available Apple machines + +> Excluded: virtualization (e.g. VirtualBox) and hackintosh + +| Machine | Memory | Storage | +| ----------- | ------ | ---------------------- | +| Mac mini | 16GB | SATA 480GB, NVMe 256GB | +| MacBook Pro | 8GB | 256GB | + +### System configuration for A3 environment + +| macOS | Version | Machine | Xcode | OpenCore | Git Client | +| ----------- | ------- | ----------- | ------ | -------- | -------------- | +| High Sierra | 10.13.6 | Mac mini | 10.1 | - | GitHub Desktop | +| Mojave | 10.14.6 | Mac mini | 11.3.1 | - | GitHub Desktop | +| Catalina | 10.15.7 | Mac mini | 11.7 | - | GitHub Desktop | +| Big Sur | 11.7.10 | Mac mini | 13.2.1 | - | GitHub Desktop | +| Monterey | 12.7.6 | Mac mini | 14.2 | - | SmartGit | +| Ventura | 13.7.4 | MacBook Pro | 15.2 | - | GitHub Desktop | +| Sonoma | 14.7.4 | MacBook Pro | 16.2 | yes | GitHub Desktop | +| Sequoia | 15.3.1 | MacBook Pro | 16.2 | yes | GitHub Desktop | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9ed09b4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+ +Dates in this file meets Gregorian calendar. Date in format YYYY-MM-DD. + +## [2.0.0] - [2025-04-27], PerseusDarkMode + +- Minimum build requirements: macOS 10.13+, iOS 11.0+, Xcode 14.2+. If standalone Xcode 10.1+. + +### Changed + +- Package structure. +- Calculation macOS Dark Mode. + +### Added + +- Approbation and Changelog docs. + +### Included + +- Functions to switch Dark Mode. +- PerseusUISystemKit classes, goes as PDMSupportingStar.swift only, out of import. + +### Improved + +- PerseusDarkMode API. +- Source Code. +- Documentation. + +### Fixed + +- PerseusDarkMode auto detect macOS DarkMode changes. + +### Updated + +- PerseusLogger to [CPL v1.1.0](https://github.com/perseusrealdeal/ConsolePerseusLogger). + +### Removed + +- Unit tests, import test only. + +## [1.1.5] - [2023-01-14], PerseusDarkMode + +- Minimum build requirements: macOS 10.9+, iOS 9.3+, and Xcode 10.1+. + +### Added + +- [PerseusLogger](https://gist.github.com/perseusrealdeal/df456a9825fcface44eca738056eb6d5). +- DarkMode feature as an extenstion variable of UIResponder/NSResponer classes iOS and macOS. +- DarkMode feature for iOS Settings bundle. +- Observing DarkMode with KVO. + +## [2022-04-01], First commit diff --git a/LICENSE b/LICENSE index 417ef55..ae281d0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,10 +1,13 @@ MIT License -Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk +Copyright © 7530 - 7533 Mikhail A. Zhigulin of Novosibirsk +Copyright © 7533 PerseusRealDeal The year starts from the creation of the world according to a Slavic calendar. September, the 1st of Slavic year. +Use Stars to adopt for the specifics you need. + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/PDMStar.swift b/PDMStar.swift new file mode 100644 index 0000000..9791618 --- /dev/null +++ b/PDMStar.swift @@ -0,0 +1,458 @@ +// +// PDMStar.swift +// Version: 2.0.0 +// +// Standalone PerseusDarkMode. +// +// +// For iOS and macOS only. Use Stars to adopt for the specifics you need. +// +// DESC: THE DARKNESS YOU CAN FORCE. +// +// Created by Mikhail Zhigulin in 7530. +// +// Copyright © 7530 - 7533 Mikhail Zhigulin of Novosibirsk +// Copyright © 7533 PerseusRealDeal +// +// All rights reserved. +// +// +// MIT License +// +// Copyright © 7530 - 7533 Mikhail A. Zhigulin of Novosibirsk +// Copyright © 7533 PerseusRealDeal +// +// The year starts from the creation of the world according to a Slavic calendar. +// September, the 1st of Slavic year. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// swiftlint:disable file_length +// + +#if canImport(UIKit) +import UIKit +#elseif canImport(Cocoa) +import Cocoa +#endif + +// import ConsolePerseusLogger + +public let APPEARANCE_DEFAULT = AppearanceStyle.light + +public let DARK_MODE_USER_CHOICE_KEY = "DarkModeUserChoiceOptionKey" +public let DARK_MODE_USER_CHOICE_DEFAULT = DarkModeOption.auto + +#if os(iOS) +public let DARK_MODE_SETTINGS_KEY = "DarkModeSettingsKey" +#endif + +#if os(macOS) +public var DARK_APPEARANCE_DEFAULT_IN_USE: NSAppearance { + guard let darkAppearanceOS = DARK_APPEARANCE_DEFAULT else { + if #available(macOS 10.14, *) { + return NSAppearance(named: .darkAqua)! + } // For HighSierra. + return NSAppearance(named: .vibrantDark)! + } + return darkAppearanceOS +} +public var DARK_APPEARANCE_DEFAULT: NSAppearance? +public var LIGHT_APPEARANCE_DEFAULT_IN_USE = NSAppearance(named: .aqua)! +#endif + +public extension Notification.Name { + static let MakeAppearanceUpNotification = + Notification.Name("MakeAppearanceUpNotification") +#if os(macOS) + static let AppleInterfaceThemeChangedNotification = + Notification.Name("AppleInterfaceThemeChangedNotification") +#endif +} + +// swiftlint:disable identifier_name +public extension NSObject { + var DarkMode: DarkMode { return DarkModeAgent.shared } + var DarkModeUserChoice: DarkModeOption { return DarkModeAgent.DarkModeUserChoice } +} +// swiftlint:enable identifier_name + +public enum AppearanceStyle: Int, CustomStringConvertible { + + case light = 0 + case dark = 1 + + public var description: String { + switch self { + case .light: + return ".light" + case .dark: + return ".dark" + } + } +} + +public enum DarkModeOption: Int, CustomStringConvertible { + + case auto = 0 + case on = 1 + case off = 2 + + public var description: String { + switch self { + case .auto: + return ".auto" + case .on: + return ".on" + case .off: + return ".off" + } + } +} + +public class DarkMode: NSObject { + + public var style: AppearanceStyle { return appearance } + + @objc public dynamic var styleObservable: Int = APPEARANCE_DEFAULT.rawValue + + internal var appearance: AppearanceStyle = APPEARANCE_DEFAULT { + didSet { styleObservable = style.rawValue } + } +} + +public class DarkModeAgent { + + // MARK: - Properties + + public static var shared: DarkMode = { _ = instance; return DarkMode() }() + public static var DarkModeUserChoice: DarkModeOption { + return userChoice + } + + // MARK: - Internals + + private static var userChoice: DarkModeOption { + get { + let rawValue = ud.valueExists(forKey: DARK_MODE_USER_CHOICE_KEY) ? + ud.integer(forKey: DARK_MODE_USER_CHOICE_KEY) : + DARK_MODE_USER_CHOICE_DEFAULT.rawValue + + if let result = DarkModeOption.init(rawValue: rawValue) { return result } + return DARK_MODE_USER_CHOICE_DEFAULT + } + set { + ud.setValue(newValue.rawValue, forKey: DARK_MODE_USER_CHOICE_KEY) +#if os(iOS) + // Set DarkMode option for Settings bundle + ud.setValue(newValue.rawValue, forKey: DARK_MODE_SETTINGS_KEY) +#endif + } + } + +#if os(macOS) + private static var distributedNCenter = DistributedNotificationCenter.default +#endif + private static var nCenter = NotificationCenter.default + private static var ud = UserDefaults.standard + + private var observation: NSKeyValueObservation? + + // MARK: - Singletone + + private static var instance = { DarkModeAgent() }() + private init() { + // log.message("[\(type(of: self))].\(#function)", .info) +#if os(macOS) + if #available(macOS 10.14, *) { + DarkModeAgent.distributedNCenter.addObserver( + self, + selector: #selector(processAppleInterfaceThemeChanged), + name: .AppleInterfaceThemeChangedNotification, + object: nil + ) + observation = NSApp.observe(\.effectiveAppearance) { _, _ in + if DarkModeAgent.userChoice == .auto { + + let effectiveAppearance = NSApplication.shared.effectiveAppearance + let wanted = self.getRequired(from: effectiveAppearance) + + DarkModeAgent.shared.appearance = wanted + self.notifyAllRegistered() + } + } + } // For HighSierra there is no need in observation cos' system style never change. +#endif + } + + // MARK: - Contract + + public static func register(stakeholder: Any, selector: Selector) { + self.nCenter.addObserver(stakeholder, + selector: selector, + name: .MakeAppearanceUpNotification, + object: nil) + } + + public static func force(_ userChoice: DarkModeOption) { + + DarkModeAgent.userChoice = userChoice + DarkModeAgent.instance.refresh() + +#if os(iOS) + DarkModeAgent.instance.notifyAllRegistered() +#elseif os(macOS) + if #unavailable(macOS 10.14) { // For HighSierra. + DarkModeAgent.instance.notifyAllRegistered() + } else if DarkModeAgent.userChoice != .auto { // If .auto observation notifies change. + DarkModeAgent.instance.notifyAllRegistered() + } +#endif + } + + public static func makeUp() { + DarkModeAgent.instance.notifyAllRegistered() + } + + // MARK: - iOS Contract specifics + +#if os(iOS) + @available(iOS 13.0, *) + public static func processTraitCollectionDidChange(_ previous: UITraitCollection?) { + if let previous = previous?.userInterfaceStyle { + if UIWindow.systemStyle.rawValue != previous.rawValue { + DarkModeAgent.instance.processAppleInterfaceThemeChanged() + } + } + } + + // Returns nil if DarkMode equal to User choice or no value saved otherwise new value. + public static func isDarkModeSettingsKeyChanged() -> DarkModeOption? { + let option = ud.valueExists(forKey: DARK_MODE_SETTINGS_KEY) ? + ud.integer(forKey: DARK_MODE_SETTINGS_KEY) : -1 + + guard option != -1, let settingsDarkMode = DarkModeOption.init(rawValue: option) + else { return nil } + + return settingsDarkMode != userChoice ? settingsDarkMode : nil + } +#endif + + // MARK: - Implementation + + @objc private func processAppleInterfaceThemeChanged() { + refresh() + notifyAllRegistered() + } + + private func refresh() { + let choice = DarkModeAgent.userChoice +#if os(iOS) + let current = UIWindow.systemStyle + let required = makeDecision(current, choice) + + if #available(iOS 13.0, *) { + UIWindow.refreshKeyWindow() + } + + DarkModeAgent.shared.appearance = required +#elseif os(macOS) + if #available(macOS 10.14, *) { + switch choice { + case .auto: + NSApp.appearance = nil + // DarkModeAgent is informed about new appearance by observation. + case .on: + NSApp.appearance = DARK_APPEARANCE_DEFAULT_IN_USE + DarkModeAgent.shared.appearance = .dark + case .off: + NSApp.appearance = LIGHT_APPEARANCE_DEFAULT_IN_USE + DarkModeAgent.shared.appearance = .light + } + } else { // For HighSierra. + DarkModeAgent.shared.appearance = choice == .on ? .dark : .light + } +#endif + } + + private func notifyAllRegistered() { + DarkModeAgent.nCenter.post(name: .MakeAppearanceUpNotification, object: nil) + } + +#if os(macOS) + private func getRequired(from appearance: NSAppearance?) -> AppearanceStyle { + let choice = DarkModeAgent.userChoice + + if #available(macOS 10.14, *) { + guard choice == .auto else { + return choice == .off ? .light : .dark + } + + if let match = appearance?.bestMatch(from: [.darkAqua, .vibrantDark]) { + return [.darkAqua, .vibrantDark].contains(match) ? .dark : .light + } + + return .light + } else { // For HighSierra. + return choice == .off ? .dark : .light + } + } +#endif +} + +// MARK: - Helpers + +extension UserDefaults { + public func valueExists(forKey key: String) -> Bool { + return object(forKey: key) != nil + } +} + +public class DarkModeObserver: NSObject { + + public var action: ((_ newStyle: AppearanceStyle) -> Void)? + + private var objectToObserve = DarkModeAgent.shared + private var observation: NSKeyValueObservation? + + public override init() { + super.init() + setupObservation() + } + + public init(_ action: @escaping ((_ newStyle: AppearanceStyle) -> Void)) { + super.init() + + self.action = action + setupObservation() + } + + private func setupObservation() { + observation = objectToObserve.observe(\.styleObservable) { _, _ in + self.action?(self.objectToObserve.style) + } + } +} + +#if os(iOS) +public enum SystemStyle: Int, CustomStringConvertible { + + case unspecified = 0 + case light = 1 + case dark = 2 + + public var description: String { + switch self { + case .unspecified: + return ".unspecified" + case .light: + return ".light" + case .dark: + return ".dark" + } + } +} + +extension UIWindow { + + static var key: UIWindow? { + if #available(iOS 13, *) { + return UIApplication.shared.windows.first { $0.isKeyWindow } + } else { + return UIApplication.shared.keyWindow + } + } + + static var systemStyle: SystemStyle { + if #available(iOS 13.0, *) { + guard let keyWindow = UIWindow.key else { return .unspecified } + + switch keyWindow.traitCollection.userInterfaceStyle { + case .unspecified: + return .unspecified + case .light: + return .light + case .dark: + return .dark + + @unknown default: + return .unspecified + } + } else { + return .unspecified // For iOS 12.0 and earlier. + } + } +} + +// MARK: - Calculating Dark Mode Required + +/// Calculates the current required appearance style of the app. +/// +/// Dark Mode decision-making: +/// +/// | User +/// -------------+----------------------- +/// System | auto | on | off +/// -------------+---------+------+------ +/// .unspecified | default | dark | light +/// .light | light | dark | light +/// .dark | dark | dark | light +/// +public func makeDecision(_ system: SystemStyle, _ user: DarkModeOption) -> AppearanceStyle { + + if (system == .unspecified) && (user == .auto) { return APPEARANCE_DEFAULT } + if (system == .unspecified) && (user == .on) { return .dark } + if (system == .unspecified) && (user == .off) { return .light } + + if (system == .light) && (user == .auto) { return .light } + if (system == .light) && (user == .on) { return .dark } + if (system == .light) && (user == .off) { return .light } + + if (system == .dark) && (user == .auto) { return .dark } + if (system == .dark) && (user == .on) { return .dark } + if (system == .dark) && (user == .off) { return .light } + + return APPEARANCE_DEFAULT + +} +#endif + +#if os(iOS) && compiler(>=5) +extension UIWindow { + + @available(iOS 13.0, *) + public static func refreshKeyWindow() { + + guard let keyWindow = UIWindow.key else { return } + + var overrideStyle: UIUserInterfaceStyle = .unspecified + + switch DarkModeAgent.DarkModeUserChoice { + case .auto: + overrideStyle = .unspecified + case .on: + overrideStyle = .dark + case .off: + overrideStyle = .light + } + + keyWindow.overrideUserInterfaceStyle = overrideStyle + } +} +#endif diff --git a/PDMSupportingStar.swift b/PDMSupportingStar.swift new file mode 100644 index 0000000..624f7d0 --- /dev/null +++ b/PDMSupportingStar.swift @@ -0,0 +1,739 @@ +// +// PDMSupportingStar.swift +// Version: 2.0.0 +// +// The Darkness Support (PerseusUISystemKit previously) +// +// +// For iOS and macOS only. Use Stars to adopt for the specifics you need. +// +// Created by Mikhail Zhigulin of Novosibirsk in 7530. +// +// The year starts from the creation of the world according to a Slavic calendar. +// September, the 1st of Slavic year. +// +// +// Unlicensed Free Software +// +// This is free and unencumbered software released into the public domain. +// +// Anyone is free to copy, modify, publish, use, compile, sell, or +// distribute this software, either in source code form or as a compiled +// binary, for any purpose, commercial or non-commercial, and by any +// means. +// +// In jurisdictions that recognize copyright laws, the author or authors +// of this software dedicate any and all copyright interest in the +// software to the public domain. We make this dedication for the benefit +// of the public at large and to the detriment of our heirs and +// successors. We intend this dedication to be an overt act of +// relinquishment in perpetuity of all present and future rights to this +// software under copyright law. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// For more information, please refer to +// +// swiftlint:disable file_length +// + +// import PerseusDarkMode + +#if canImport(UIKit) +import UIKit +#elseif canImport(Cocoa) +import Cocoa +#endif + +#if os(iOS) +public typealias Color = UIColor +#elseif os(macOS) +public typealias Color = NSColor +#endif + +public func rgba255(_ red: CGFloat, + _ green: CGFloat, + _ blue: CGFloat, + _ alpha: CGFloat = 1.0) -> Color { + return Color(red: red/255, green: green/255, blue: blue/255, alpha: alpha) +} + +public extension Color { + + var RGBA255: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return (red*255, green*255, blue*255, alpha) + } +} + +public protocol SemanticColorProtocol { + +// MARK: - FOREGROUND CONTENT + + // MARK: - Label Colors + + /// Label. + /// + /// - Light: 0, 0, 0 + /// - Dark: 255, 255, 255 + static var labelPerseus: Color { get } + + /// Secondary label. + /// + /// - Light: 60, 60, 67, 0.6 + /// - Dark: 235, 235, 245, 0.6 + static var secondaryLabelPerseus: Color { get } + + /// Tertiary label. + /// + /// - Light: 60, 60, 67, 0.3 + /// - Dark: 235, 235, 245, 0.3 + static var tertiaryLabelPerseus: Color { get } + + /// Quaternary label. + /// + /// - Light: 60, 60, 67, 0.18 + /// - Dark: 235, 235, 245, 0.16 + static var quaternaryLabelPerseus: Color { get } + + // MARK: - Text Colors + + /// Placeholder text. + /// + /// - Light: 60, 60, 67, 0.3 + /// - Dark: 235, 235, 245, 0.3 + static var placeholderTextPerseus: Color { get } + + // MARK: - Separator Colors + + /// Separator. + /// + /// - Light: 60, 60, 67, 0.29 + /// - Dark: 84, 84, 88, 0.6 + static var separatorPerseus: Color { get } + + /// Opaque separator. + /// + /// - Light: 198, 198, 200 + /// - Dark: 56, 56, 58 + static var opaqueSeparatorPerseus: Color { get } + + // MARK: - Link Color + + /// Link. + /// + /// - Light: 0, 122, 255 + /// - Dark: 9, 132, 255 + static var linkPerseus: Color { get } + + // MARK: - Fill Colors + + /// System fill. + /// + /// - Light: 120, 120, 128, 0.2 + /// - Dark: 120, 120, 128, 0.36 + static var systemFillPerseus: Color { get } + + /// Secondary system fill. + /// + /// - Light: 120, 120, 128, 0.16 + /// - Dark: 120, 120, 128, 0.32 + static var secondarySystemFillPerseus: Color { get } + + /// Tertiary system fill. + /// + /// - Light: 118, 118, 128, 0.12 + /// - Dark: 118, 118, 128, 0.24 + static var tertiarySystemFillPerseus: Color { get } + + /// Quaternary system fill. + /// + /// - Light: 116, 116, 128, 0.08 + /// - Dark: 118, 118, 128, 0.18 + static var quaternarySystemFillPerseus: Color { get } + +// MARK: - BACKGROUND CONTENT + + // MARK: - Standard + + /// System background. + /// + /// - Light: 255, 255, 255 + /// - Dark: 28, 28, 30 + static var systemBackgroundPerseus: Color { get } + + /// Secondary system background. + /// + /// - Light: 242, 242, 247 + /// - Dark: 44, 44, 46 + static var secondarySystemBackgroundPerseus: Color { get } + + /// Tertiary system background. + /// + /// - Light: 255, 255, 255 + /// - Dark: 58, 58, 60 + static var tertiarySystemBackgroundPerseus: Color { get } + + // MARK: - Grouped + + /// System grouped background. + /// + /// - Light: 242, 242, 247 + /// - Dark: 28, 28, 30 + static var systemGroupedBackgroundPerseus: Color { get } + + /// Secondary system grouped background. + /// + /// - Light: 255, 255, 255 + /// - Dark: 44, 44, 46 + static var secondarySystemGroupedBackgroundPerseus: Color { get } + + /// Tertiary system grouped background. + /// + /// - Light: 242, 242, 247 + /// - Dark: 58, 58, 60 + static var tertiarySystemGroupedBackgroundPerseus: Color { get } +} + +public protocol SystemColorProtocol { + + /// Red is .systemRed + /// + /// - Light: 255, 59, 48 + /// - Dark: 255, 69, 58 + static var perseusRed: Color { get } + + /// Orange is .systemOrange + /// + /// - Light: 255, 149, 0 + /// - Dark: 255, 159, 10 + static var perseusOrange: Color { get } + + /// Yellow is .systemYellow + /// + /// - Light: 255, 204, 0 + /// - Dark: 255, 214, 10 + static var perseusYellow: Color { get } + + /// Green is .systemGreenGreen + /// + /// - Light: 52, 199, 89 + /// - Dark: 48, 209, 88 + static var perseusGreen: Color { get } + + /// Mint is .systemMint + /// + /// - Light: 0, 199, 190 + /// - Dark: 102, 212, 207 + static var perseusMint: Color { get } + + /// Teal is .systemTeal + /// + /// - Light: 48, 176, 199 + /// - Dark: 64, 200, 224 + static var perseusTeal: Color { get } + + /// Cyan is .systemCyan + /// + /// - Light: 50, 173, 230 + /// - Dark: 100, 210, 255 + static var perseusCyan: Color { get } + + /// Blue is .systemBlue + /// + /// - Light: 0, 122, 255 + /// - Dark: 10, 132, 255 + static var perseusBlue: Color { get } + + /// Indigo is .systemIndigo + /// + /// - Light: 88, 86, 214 + /// - Dark: 94, 92, 230 + static var perseusIndigo: Color { get } + + /// Purple is .systemPurple + /// + /// - Light: 175, 82, 222 + /// - Dark: 191, 90, 242 + static var perseusPurple: Color { get } + + /// Pink is .systemPink + /// + /// - Light: 255, 45, 85 + /// - Dark: 255, 55, 95 + static var perseusPink: Color { get } + + /// Brown is .systemBrown + /// + /// - Light: 162, 132, 94 + /// - Dark: 172, 142, 104 + static var perseusBrown: Color { get } + +// MARK: - System Gray Colors + + /// Gray is .systemGray + /// + /// - Light: 142, 142, 147 + /// - Dark: 142, 142, 147 + static var perseusGray: Color { get } + + /// Gray (2) is .systemGray62 + /// + /// - Light: 174, 174, 178 + /// - Dark: 99, 99, 102 + static var perseusGray2: Color { get } + + /// Gray (3) is .systemGray3 + /// + /// - Light: 199, 199, 204 + /// - Dark: 72, 72, 74 + static var perseusGray3: Color { get } + + /// Gray (4) is .systemGray4 + /// + /// - Light: 209, 209, 214 + /// - Dark: 58, 58, 60 + static var perseusGray4: Color { get } + + /// Gray (5) is .systemGray5 + /// + /// - Light: 229, 229, 234 + /// - Dark: 44, 44, 46 + static var perseusGray5: Color { get } + + /// Gray (6) is .systemGray6 + /// + /// - Light: 242, 242, 247 + /// - Dark: 28, 28, 30 + static var perseusGray6: Color { get } +} + +extension Color: SemanticColorProtocol { + + /// .label + public static var labelPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(0, 0, 0) : rgba255(255, 255, 255) + } + + /// .secondaryLabel + public static var secondaryLabelPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(60, 60, 67, 0.6) : rgba255(235, 235, 245, 0.6) + } + + /// .tertiaryLabel + public static var tertiaryLabelPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(60, 60, 67, 0.3) : rgba255(235, 235, 245, 0.3) + } + + /// .quaternaryLabel + public static var quaternaryLabelPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(60, 60, 67, 0.18) : rgba255(235, 235, 245, 0.16) + } + + /// .placeholderText + public static var placeholderTextPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(60, 60, 67, 0.3) : rgba255(235, 235, 245, 0.3) + } + + /// .separator + public static var separatorPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(60, 60, 67, 0.29) : rgba255(84, 84, 88, 0.6) + } + + /// .opaqueSeparator + public static var opaqueSeparatorPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(198, 198, 200) : rgba255(56, 56, 58) + } + + /// .link + public static var linkPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(0, 122, 255) : rgba255(9, 132, 255) + } + + /// .systemFill + public static var systemFillPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(120, 120, 128, 0.2) : rgba255(120, 120, 128, 0.36) + } + + /// .secondarySystemFill + public static var secondarySystemFillPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(120, 120, 128, 0.16) : rgba255(120, 120, 128, 0.32) + } + + /// .tertiarySystemFill + public static var tertiarySystemFillPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(118, 118, 128, 0.12) : rgba255(118, 118, 128, 0.24) + } + + /// .quaternarySystemFill + public static var quaternarySystemFillPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(116, 116, 128, 0.08) : rgba255(118, 118, 128, 0.18) + } + + /// .systemBackground + public static var systemBackgroundPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(255, 255, 255) : rgba255(28, 28, 30) + } + + /// .secondarySystemBackground + public static var secondarySystemBackgroundPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(242, 242, 247) : rgba255(44, 44, 46) + } + + /// .tertiarySystemBackground + public static var tertiarySystemBackgroundPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(255, 255, 255) : rgba255(58, 58, 60) + } + + /// .systemGroupedBackground + public static var systemGroupedBackgroundPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(242, 242, 247) : rgba255(28, 28, 30) + } + + /// .secondarySystemGroupedBackground + public static var secondarySystemGroupedBackgroundPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(255, 255, 255) : rgba255(44, 44, 46) + } + + /// .tertiarySystemGroupedBackground + public static var tertiarySystemGroupedBackgroundPerseus: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(242, 242, 247) : rgba255(58, 58, 60) + } +} + +extension Color: SystemColorProtocol { + + /// .systemRed + public static var perseusRed: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(255, 59, 48) : rgba255(255, 69, 58) + } + + /// .systemOrange + public static var perseusOrange: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(255, 149, 0) : rgba255(255, 159, 10) + } + + /// .systemYellow + public static var perseusYellow: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(255, 204, 0) : rgba255(255, 214, 10) + } + + /// .systemGreen + public static var perseusGreen: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(52, 199, 89) : rgba255(48, 209, 88) + } + + /// .systemMint + public static var perseusMint: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(0, 199, 190) : rgba255(102, 212, 207) + } + + /// .systemTeal + public static var perseusTeal: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(48, 176, 199) : rgba255(64, 200, 224) + } + + /// .systemCyan + public static var perseusCyan: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(50, 173, 230) : rgba255(100, 210, 255) + } + + /// .systemBlue + public static var perseusBlue: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(0, 122, 255) : rgba255(10, 132, 255) + } + + /// .systemIndigo + public static var perseusIndigo: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(88, 86, 214) : rgba255(94, 92, 230) + } + + /// .systemPurple + public static var perseusPurple: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(175, 82, 222) : rgba255(191, 90, 242) + } + + /// .systemPink + public static var perseusPink: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(255, 45, 85) : rgba255(255, 55, 95) + } + + /// .systemBrown + public static var perseusBrown: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(162, 132, 94) : rgba255(172, 142, 104) + } + + /// .systemGray + public static var perseusGray: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(142, 142, 147) : rgba255(142, 142, 147) + } + + /// .systemGray2 + public static var perseusGray2: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(174, 174, 178) : rgba255(99, 99, 102) + } + + /// .systemGray3 + public static var perseusGray3: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(199, 199, 204) : rgba255(72, 72, 74) + } + + /// .systemGray4 + public static var perseusGray4: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(209, 209, 214) : rgba255(58, 58, 60) + } + + /// .systemGray5 + public static var perseusGray5: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(229, 229, 234) : rgba255(44, 44, 46) + } + + /// .systemGray6 + public static var perseusGray6: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(242, 242, 247) : rgba255(28, 28, 30) + } +} + +#if os(iOS) + +@IBDesignable +public class DarkModeImageView: UIImageView { + + @IBInspectable + public var imageLight: UIImage? { + didSet { + light = imageLight + image = DarkMode.style == .light ? light : dark + } + } + + @IBInspectable + public var imageDark: UIImage? { + didSet { + dark = imageDark + image = DarkMode.style == .light ? light : dark + } + } + + private(set) var theDarknessTrigger: DarkModeObserver? + + private(set) var light: UIImage? + private(set) var dark: UIImage? + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + configure() + } + + private func configure() { + theDarknessTrigger = DarkModeObserver { style in + self.image = style == .light ? self.light : self.dark + } + + image = DarkMode.style == .light ? self.light : self.dark + } + + public func configure(_ light: UIImage?, _ dark: UIImage?) { + self.light = light + self.dark = dark + + theDarknessTrigger?.action = { style in + self.image = style == .light ? self.light : self.dark + } + + image = DarkMode.style == .light ? self.light : self.dark + } +} + +#elseif os(macOS) + +public enum ScaleImageViewMacOS: Int, CustomStringConvertible { + + case scaleNone = 0 // No scale at all + case axesIndependently = 1 // Aspect Fill + case proportionallyUpOrDown = 2 // Aspect Fit + case proportionallyDown = 3 // Center Top + case proportionallyClipToBounds = 4 // Aspect Fill with cliping to ImageView bounds + + public var description: String { + switch self { + case .scaleNone: + return "As is, no scaling." + case .axesIndependently: + return "Aspect Fill." + case .proportionallyUpOrDown: + return "Aspect Fit." + case .proportionallyDown: + return "Center Top." + case .proportionallyClipToBounds: + return "Aspect Fill cliped to bounds." + } + } + + public var value: NSImageScaling { + switch self { + case .scaleNone: + return .scaleNone + case .axesIndependently: + return .scaleAxesIndependently + case .proportionallyUpOrDown: + return .scaleProportionallyUpOrDown + case .proportionallyDown: + return .scaleProportionallyDown + case .proportionallyClipToBounds: + return .scaleNone + } + } +} + +@IBDesignable +public class DarkModeImageView: NSImageView { + + @IBInspectable + public var imageLight: NSImage? { + didSet { + image = DarkMode.style == .light ? imageLight : imageDark + } + } + + @IBInspectable + public var imageDark: NSImage? { + didSet { + image = DarkMode.style == .light ? imageLight : imageDark + } + } + + @IBInspectable + public var aspectFillClipToBounds: Bool = false + + public var customScale: ScaleImageViewMacOS = .scaleNone { + didSet { + guard customScale != .proportionallyClipToBounds else { + + self.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + self.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + self.aspectFillClipToBounds = true + self.imageScaling = .scaleNone + + return + } + + self.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + self.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + + self.aspectFillClipToBounds = false + self.imageScaling = customScale.value + } + } + + private(set) var theDarknessTrigger: DarkModeObserver? + + override public func awakeFromNib() { + guard aspectFillClipToBounds else { return } + + self.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + self.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + self.imageScaling = .scaleNone + } + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + configure() + } + + public func configure(_ light: NSImage?, _ dark: NSImage?) { + configure() + + self.imageLight = light + self.imageDark = dark + } + + private func configure() { + theDarknessTrigger = DarkModeObserver { style in + self.image = style == .light ? self.imageLight : self.imageDark + } + } + + override public func draw(_ dirtyRect: NSRect) { + guard aspectFillClipToBounds, let image = self.image else { + super.draw(dirtyRect) + return + } + + let viewWidth = self.bounds.size.width + let viewHeight = self.bounds.size.height + + let width = image.size.width + let height = image.size.height + + let imageViewRatio = viewWidth / viewHeight + let imageRatio = width / height + + image.size.width = imageRatio < imageViewRatio ? viewWidth : viewHeight * imageRatio + image.size.height = imageRatio < imageViewRatio ? viewWidth / imageRatio : viewHeight + + super.draw(dirtyRect) + } +} + +#endif diff --git a/Package.swift b/Package.swift index 7b06ba7..ad7aada 100644 --- a/Package.swift +++ b/Package.swift @@ -1,23 +1,30 @@ -// swift-tools-version:4.2 +// swift-tools-version:5.7 /* Package.swift - Version: 1.1.5 + Version: 2.0.0 + + For iOS and macOS only. Use Stars to adopt for the specifics you need. Created by Mikhail Zhigulin in 7530. - Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. + Copyright © 7530 - 7533 Mikhail A. Zhigulin of Novosibirsk + Copyright © 7533 PerseusRealDeal Licensed under the MIT license. See LICENSE file. All rights reserved. Abstract: - Package manifest for Perseus Dark Mode. + Package manifest for the Darkness. */ import PackageDescription let package = Package( name: "PerseusDarkMode", + platforms: [ + .macOS(.v10_13), + .iOS(.v11) + ], products: [ .library( name: "PerseusDarkMode", @@ -31,7 +38,7 @@ let package = Package( name: "PerseusDarkMode", dependencies: []), .testTarget( - name: "DarkModeTests", + name: "UnitTests", dependencies: ["PerseusDarkMode"]) ] ) diff --git a/PerseusDarkMode.podspec b/PerseusDarkMode.podspec deleted file mode 100644 index 98d3394..0000000 --- a/PerseusDarkMode.podspec +++ /dev/null @@ -1,22 +0,0 @@ -Pod::Spec.new do |p| - -p.name = "PerseusDarkMode" -p.version = "1.1.4" -p.summary = "Variable for changing Dark Mode." -p.description = "Designed for constructing Dark Mode sensitive features." -p.homepage = "https://github.com/perseusrealdeal/PerseusDarkMode" - -p.license = { :type => "MIT", :file => "LICENSE" } -p.author = { "perseusrealdeal" => "mzhigulin@gmail.com" } - -p.source = { :git => "https://github.com/perseusrealdeal/PerseusDarkMode.git", :tag => p.version.to_s } - -p.ios.deployment_target = '9.3' -p.osx.deployment_target = '10.9' - -p.swift_version = "4.2" -p.requires_arc = true - -p.source_files = 'Sources/**/*.swift' - -end diff --git a/PerseusDarkModeSingle.swift b/PerseusDarkModeSingle.swift deleted file mode 100644 index 8d8f720..0000000 --- a/PerseusDarkModeSingle.swift +++ /dev/null @@ -1,451 +0,0 @@ -// -// PerseusDarkModeSingle.swift -// Version: 1.1.5 -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// All rights reserved. -// -// -// MIT License -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk -// -// The year starts from the creation of the world according to a Slavic calendar. -// September, the 1st of Slavic year. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// -// swiftlint:disable file_length block_based_kvo -// - -#if canImport(UIKit) -import UIKit -#elseif canImport(Cocoa) -import Cocoa -#endif - -#if os(iOS) -public typealias Responder = UIResponder -#elseif os(macOS) -public typealias Responder = NSResponder -#endif - -// MARK: - Notifications - -public extension Notification.Name { - static let MakeAppearanceUpNotification = - Notification.Name("MakeAppearanceUpNotification") -#if os(macOS) - static let AppleInterfaceThemeChangedNotification = - Notification.Name("AppleInterfaceThemeChangedNotification") -#endif -} - -// MARK: - Constants - -public let DARK_MODE_USER_CHOICE_KEY = "DarkModeUserChoiceOptionKey" -public let DARK_MODE_USER_CHOICE_DEFAULT = DarkModeOption.auto -public let DARK_MODE_STYLE_DEFAULT = AppearanceStyle.light -public let OBSERVERED_VARIABLE_NAME = "styleObservable" - -// MARK: - Appearance service - -// swiftlint:disable identifier_name -public extension Responder { - var DarkMode: DarkMode { return AppearanceService.shared } -} -// swiftlint:enable identifier_name - -public class AppearanceService { - - public static var shared: DarkMode = { _ = it; return DarkMode() }() - - private(set) static var it = { AppearanceService() }() - private init() { -#if os(macOS) - AppearanceService.distributedNCenter.addObserver( - self, - selector: #selector(interfaceModeChanged), - name: .AppleInterfaceThemeChangedNotification, - object: nil - ) -#endif - } - -#if os(macOS) - @objc internal func interfaceModeChanged() { - if #available(macOS 10.14, *) { - AppearanceService.processAppearanceOSDidChange() - } - } - - @available(macOS 10.14, *) - public static var defaultDarkAppearanceOS: NSAppearance.Name = .darkAqua - public static var defaultLightAppearanceOS: NSAppearance.Name = .aqua -#endif - - public static var isEnabled: Bool { return hidden_isEnabled } - -#if os(macOS) - /// Default Distributed NotificationCenter. - public static var distributedNCenter = DistributedNotificationCenter.default -#endif - - /// Default NotificationCenter. - public static var nCenter = NotificationCenter.default - /// Default UserDefaults. - public static var ud = UserDefaults.standard - - public static var DarkModeUserChoice: DarkModeOption { - get { - // Load enum Int value - - let rawValue = ud.valueExists(forKey: DARK_MODE_USER_CHOICE_KEY) ? - ud.integer(forKey: DARK_MODE_USER_CHOICE_KEY) : - DARK_MODE_USER_CHOICE_DEFAULT.rawValue - - // Try to cast Int value to enum - - if let result = DarkModeOption.init(rawValue: rawValue) { return result } - - return DARK_MODE_USER_CHOICE_DEFAULT - } - set { - ud.setValue(newValue.rawValue, forKey: DARK_MODE_USER_CHOICE_KEY) - - // Used for KVO to immediately notify a change has happened - recalculateStyleIfNeeded() - } - } - - // MARK: - Public API: register stakeholder - - public static func register(stakeholder: Any, selector: Selector) { - nCenter.addObserver(stakeholder, - selector: selector, - name: .MakeAppearanceUpNotification, - object: nil) - } - - // MARK: - Public API: make the app's appearance up - - public static func makeUp() { - hidden_isEnabled = true - hidden_changeManually = true - - if #available(iOS 13.0, macOS 10.14, *) { overrideUserInterfaceStyleIfNeeded() } - - recalculateStyleIfNeeded() - - nCenter.post(name: .MakeAppearanceUpNotification, object: nil) - hidden_changeManually = false - } - -#if os(iOS) - @available(iOS 13.0, *) - public static func processTraitCollectionDidChange( - _ previousTraitCollection: UITraitCollection?) { - if hidden_changeManually { return } - - guard let previousSystemStyle = previousTraitCollection?.userInterfaceStyle, - previousSystemStyle.rawValue != shared.systemStyle.rawValue - else { return } - - hidden_systemCalledMakeUp() - } -#elseif os(macOS) - @available(macOS 10.14, *) - internal static func processAppearanceOSDidChange() { - if hidden_changeManually { return } - hidden_systemCalledMakeUp() - } -#endif - - // MARK: - Implementation helpers, privates and internals - - private(set) static var hidden_isEnabled: Bool = false { - willSet { if newValue == false { return }} - } - - internal static var hidden_changeManually: Bool = false - - internal static func hidden_systemCalledMakeUp() { - if hidden_changeManually { return } - - hidden_isEnabled = true - - recalculateStyleIfNeeded() - nCenter.post(name: .MakeAppearanceUpNotification, object: nil) - } - - public static func recalculateStyleIfNeeded() { - let actualStyle = DarkModeDecision.calculate(DarkModeUserChoice, shared.systemStyle) - - if shared.hidden_style != actualStyle { shared.hidden_style = actualStyle } - } - - @available(iOS 13.0, macOS 10.14, *) - internal static func overrideUserInterfaceStyleIfNeeded() { - if hidden_changeManually == false { return } -#if os(iOS) && compiler(>=5) - guard let keyWindow = UIWindow.key else { return } - var overrideStyle: UIUserInterfaceStyle = .unspecified - - switch DarkModeUserChoice { - case .auto: - overrideStyle = .unspecified - - case .on: - overrideStyle = .dark - - case .off: - overrideStyle = .light - } - - keyWindow.overrideUserInterfaceStyle = overrideStyle - -#elseif os(macOS) - switch DarkModeUserChoice { - case .auto: - NSApplication.shared.appearance = nil - case .on: - NSApplication.shared.appearance = - NSAppearance(named: AppearanceService.defaultDarkAppearanceOS) - case .off: - NSApplication.shared.appearance = - NSAppearance(named: AppearanceService.defaultLightAppearanceOS) - } -#endif - } -} - -// MARK: - Dark Mode - -public class DarkMode: NSObject { - - // MARK: - The App's current Appearance Style - - public var style: AppearanceStyle { return hidden_style } - - // MARK: - Observable Appearance Style Value (Using Key-Value Observing) - - @objc public dynamic var styleObservable: Int = DARK_MODE_STYLE_DEFAULT.rawValue - - // MARK: - System's Appearance Style - - public var systemStyle: SystemStyle { - if #available(iOS 13.0, macOS 10.14, *) { -#if os(iOS) - guard let keyWindow = UIWindow.key else { return .unspecified } - - switch keyWindow.traitCollection.userInterfaceStyle { - case .unspecified: - return .unspecified - case .light: - return .light - case .dark: - return .dark - - @unknown default: - return .unspecified - } -#elseif os(macOS) - if let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle"), - isDark == "Dark" { - return .dark - } else { - return .light - } -#endif - } else { - return .unspecified - } - } - - internal var hidden_style: AppearanceStyle = DARK_MODE_STYLE_DEFAULT { - didSet { styleObservable = style.rawValue } - } -} - -// MARK: - Dark Mode decision-making table - -public class DarkModeDecision { - private init() { } - - // MARK: - Calculating Dark Mode decision - - /// Calculates the current appearance style of the app. - /// - /// Dark Mode decision-making: - /// - /// | DarkModeOption - /// -------------+----------------------- - /// SystemStyle | auto | on | off - /// -------------+---------+------+------ - /// .unspecified | default | dark | light - /// .light | light | dark | light - /// .dark | dark | dark | light - /// - public class func calculate(_ userChoice: DarkModeOption, - _ systemStyle: SystemStyle) -> AppearanceStyle { - // Calculate outputs - - if (systemStyle == .unspecified) && (userChoice == .auto) { - return DARK_MODE_STYLE_DEFAULT - } - if (systemStyle == .unspecified) && (userChoice == .on) { return .dark } - if (systemStyle == .unspecified) && (userChoice == .off) { return .light } - - if (systemStyle == .light) && (userChoice == .auto) { return .light } - if (systemStyle == .light) && (userChoice == .on) { return .dark } - if (systemStyle == .light) && (userChoice == .off) { return .light } - - if (systemStyle == .dark) && (userChoice == .auto) { return .dark } - if (systemStyle == .dark) && (userChoice == .on) { return .dark } - if (systemStyle == .dark) && (userChoice == .off) { return .light } - - // Output default value if somethings goes out of the decision table - - return DARK_MODE_STYLE_DEFAULT - } -} - -// MARK: - Appearance Style Observering - -public class DarkModeObserver: NSObject { - - public var action: ((_ newStyle: AppearanceStyle) -> Void)? - private(set) var objectToObserve = AppearanceService.shared - - public override init() { - super.init() - - objectToObserve.addObserver(self, - forKeyPath: OBSERVERED_VARIABLE_NAME, - options: .new, - context: nil) - } - - public init(_ action: @escaping ((_ newStyle: AppearanceStyle) -> Void)) { - super.init() - - self.action = action - objectToObserve.addObserver(self, - forKeyPath: OBSERVERED_VARIABLE_NAME, - options: .new, - context: nil) - } - - public override func observeValue(forKeyPath keyPath: String?, - of object: Any?, - change: [NSKeyValueChangeKey: Any]?, - context: UnsafeMutableRawPointer?) { - guard - keyPath == OBSERVERED_VARIABLE_NAME, - let style = change?[.newKey], - let styleRawValue = style as? Int, - let newStyle = AppearanceStyle.init(rawValue: styleRawValue) - else { return } - - action?(newStyle) - } - - deinit { - objectToObserve.removeObserver(self, forKeyPath: OBSERVERED_VARIABLE_NAME) - } -} - -// MARK: - Dark Mode Option - -public enum DarkModeOption: Int, CustomStringConvertible { - - case auto = 0 - case on = 1 - case off = 2 - - public var description: String { - switch self { - case .auto: - return ".auto" - case .on: - return ".on" - case .off: - return ".off" - } - } -} - -// MARK: - Appearance Style - -public enum AppearanceStyle: Int, CustomStringConvertible { - - case light = 0 - case dark = 1 - - public var description: String { - switch self { - case .light: - return ".light" - case .dark: - return ".dark" - } - } -} - -// MARK: - System Style - -public enum SystemStyle: Int, CustomStringConvertible { - - case unspecified = 0 - case light = 1 - case dark = 2 - - public var description: String { - switch self { - case .unspecified: - return ".unspecified" - case .light: - return ".light" - case .dark: - return ".dark" - } - } -} - -// MARK: - Helpers - -extension UserDefaults { - public func valueExists(forKey key: String) -> Bool { - return object(forKey: key) != nil - } -} - -#if os(iOS) -extension UIWindow { - static var key: UIWindow? { - if #available(iOS 13, *) { - return UIApplication.shared.windows.first { $0.isKeyWindow } - } else { - return UIApplication.shared.keyWindow - } - } -} -#endif diff --git a/README.md b/README.md index 1337e00..719f3f2 100644 --- a/README.md +++ b/README.md @@ -1,204 +1,359 @@ -# Perseus Dark Mode +# PerseusDarkMode — Xcode 14.2+ -[![Actions Status](https://github.com/perseusrealdeal/DarkMode/actions/workflows/main.yml/badge.svg)](https://github.com/perseusrealdeal/PerseusDarkMode/actions) -![Version](https://img.shields.io/badge/Version-1.1.5-informational.svg) -[![Pod](https://img.shields.io/badge/Pod-1.1.4-informational.svg)](/PerseusDarkMode.podspec) -![Platforms](https://img.shields.io/badge/Platforms-iOS%209.3+,%20macOS%2010.9+-orange.svg) -[![Swift 4.2](https://img.shields.io/badge/Swift-4.2-red.svg)](https://docs.swift.org/swift-book/RevisionHistory/RevisionHistory.html) +[`iOS approbation app`](https://github.com/perseusrealdeal/TheOneRing) [`macOS approbation app`](https://github.com/perseusrealdeal/Arkenstone) + +> The light-weight darkness in Swift you can force. Hereinafter PDM stands for Perseus Dark Mode.
+ +> - To build option kinda `Night/Day/System Mode` or `On/Off/System Dark Mode`.
+> - To be awared of Dark Mode changes if you need.
+ +> `PDM` is a single author and personale solution developed in `person-to-person` relationship paradigm. + +[![Actions Status](https://github.com/perseusrealdeal/PerseusDarkMode/actions/workflows/main.yml/badge.svg)](https://github.com/perseusrealdeal/PerseusDarkMode/actions/workflows/main.yml) +[![Style](https://github.com/perseusrealdeal/PerseusDarkMode/actions/workflows/swiftlint.yml/badge.svg)](https://github.com/perseusrealdeal/PerseusDarkMode/actions/workflows/swiftlint.yml) +[![Version](https://img.shields.io/badge/Version-2.0.0-green.svg)](/CHANGELOG.md) +[![Platforms](https://img.shields.io/badge/Platforms-macOS%2010.13+Cocoa_|_iOS%2011.0+UIKit-orange.svg)](https://en.wikipedia.org/wiki/List_of_Apple_products) +[![Xcode 14.2](https://img.shields.io/badge/Xcode-14.2+-red.svg)](https://en.wikipedia.org/wiki/Xcode) +[![Swift 5.7](https://img.shields.io/badge/Swift-5.7-red.svg)](https://www.swift.org) [![License](http://img.shields.io/:License-MIT-blue.svg)](/LICENSE) ## Integration Capabilities -[![Standalone](https://img.shields.io/badge/Standalone-available-informational.svg)](/PerseusDarkModeSingle.swift) -[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg)](https://github.com/Carthage/Carthage) -[![CocoaPods manager](https://img.shields.io/badge/CocoaPods-compatible-4BC51D.svg)](/PerseusDarkMode.podspec) +[![Standalone](https://img.shields.io/badge/Standalone-available-informational.svg)](/PDMStar.swift) [![Swift Package Manager compatible](https://img.shields.io/badge/Swift%20Package%20Manager-compatible-4BC51D.svg)](/Package.swift) -## Demo Apps and Others +> Use Stars to adopt [`PDM`](/PDMStar.swift) for the specifics you need. -[![Demo App](https://img.shields.io/badge/iOS%20Demo%20App-available-yellow.svg)](https://github.com/perseusrealdeal/ios.darkmode.discovery.git) -[![Demo App](https://img.shields.io/badge/macOS%20Demo%20App-available-yellow.svg)](https://github.com/perseusrealdeal/macos.darkmode.discovery.git) -[![PerseusUISystemKit](http://img.shields.io/:Satellite-PerseusUISystemKit-blue.svg)](https://github.com/perseusrealdeal/PerseusUISystemKit.git) -[![XcodeTemplateProject](http://img.shields.io/:Template-XcodeTemplateProject-blue.svg)](https://github.com/perseusrealdeal/XcodeTemplateProject.git) +# Support Code -# In Brief +[![Standalone](https://img.shields.io/badge/Standalone-available-informational.svg)](/PDMSupportingStar.swift) +[![License](http://img.shields.io/:License-Unlicense-green.svg)](http://unlicense.org/) -> This library lets a developer being awared of Dark Mode via a variable `DarkMode.style`. Also, with this library it is possible to change the value of Dark Mode in runtime easily with standalone lib [DarkModeSwitching](https://gist.github.com/perseusrealdeal/11b1bab47f13134832b859f49d9af706). +> [`PDMSupportingStar.swift`](/PDMSupportingStar.swift) is a peace of code a widly helpful in accord with PDM. -```swift -changeDarkModeManually(.off) // .on or .auto -``` -Sample screen for changing Dark Mode option [here for macOS](https://github.com/perseusrealdeal/macos.darkmode.discovery) and [here for iOS](https://github.com/perseusrealdeal/ios.darkmode.discovery). +> `PDMSupportingStar.swift` goes as an external part of `PDM`. + +## Approbation Matrix -# Reqirements +> [`A3 Environment and Approbation`](/APPROBATION.md) / [`CHANGELOG`](/CHANGELOG.md) for details. -- Xcode 10.1+ -- Swift 4.2+ -- iOS: 9.3+, UIKit SDK -- macOS: 10.9+, AppKit SDK +## In brief > Idea to use, the Why -# First-party software +> THE DARKNESS YOU CAN FORCE.
-- [PerseusLogger](https://gist.github.com/perseusrealdeal/df456a9825fcface44eca738056eb6d5) + + + + + + + + + + + +
iOS windowiOS Settings bundlemacOS window
+
+ +
-# Third-party software +## Build requirements -- [SwiftLint Shell Script Runner](/SucceedsPostAction.sh) -- [SwiftLint](https://github.com/realm/SwiftLint) / [0.31.0: Busy Laundromat](https://github.com/realm/SwiftLint/releases/tag/0.31.0) for macOS High Sierra +- [macOS Monterey 12.7.6+](https://apps.apple.com/by/app/macos-monterey/id1576738294) / [Xcode 14.2+](https://developer.apple.com/services-account/download?path=/Developer_Tools/Xcode_14.2/Xcode_14.2.xip) + +> But as the single source code file [PDMStar.swift](/PDMStar.swift) PDM can be used even in Xcode 10.1. + +## First-party software + +- [ConsolePerseusLogger](https://github.com/perseusrealdeal/ConsolePerseusLogger) / [1.1.0](https://github.com/perseusrealdeal/ConsolePerseusLogger/releases/tag/1.1.0) + +## Third-party software + +- Style [SwiftLint](https://github.com/realm/SwiftLint) / [Shell Script](/SucceedsPostAction.sh) +- Action [mxcl/xcodebuild@v3](https://github.com/mxcl/xcodebuild/releases/tag/v3.5.1) +- Action [cirruslabs/swiftlint-action@v1](https://github.com/cirruslabs/swiftlint-action/releases/tag/v1.0.0) # Installation -> ***Using "Exact" with the Version field is strongly recommended.*** +`Step 1:` Import the Darkness either with SPM or standalone + +> Standalone: the single source code file [PDMStar.swift](/PDMStar.swift) -## Step 1: Add PerseusDarkMode to a host project tree +> Swift Package Manager: `https://github.com/perseusrealdeal/PerseusDarkMode` -### Standalone +## Steps for Cocoa macOS project -Make a copy of the file [`PerseusDarkModeSingle.swift`](/PerseusDarkModeSingle.swift) then put it into a place required of a host project. +`Step 2:` In the AppDelegate when `applicationDidFinishLaunching` call `force` -### Carthage +```swift + +import Cocoa +import PerseusDarkMode -Cartfile should contain: +class AppDelegate: NSObject, NSApplicationDelegate { + + func applicationDidFinishLaunching(_ aNotification: Notification) { + DarkModeAgent.force(DarkModeUserChoice) + } +} -```carthage -github "perseusrealdeal/PerseusDarkMode" == 1.1.5 ``` -Some Carthage usage tips placed [here](https://gist.github.com/perseusrealdeal/8951b10f4330325df6347aaaa79d3cf2). +`Step 3:` Register the MainWindowController for Dark Mode changes + +```swift -### CocoaPods +import Cocoa +import PerseusDarkMode -Podfile should contain: +class MainWindowController: NSWindowController, NSWindowDelegate { + + override func windowDidLoad() { + super.windowDidLoad() + + DarkModeAgent.register(stakeholder: self, selector: #selector(makeUp)) + } + + @objc private func makeUp() { + + // Runs every time if Dark Mode changes. + // The current DarkMode value is reliable here. + + let isDark = DarkMode.style == .dark + let _ = isDark ? "It's dark" : "No dark" + + } +} -```ruby -target "ProjectTarget" do - use_frameworks! - pod 'PerseusDarkMode', '1.1.4' -end ``` -### Swift Package Manager +## Steps for UIKit iOS project -- As a package dependency so Package.swift should contain the following statements: +`Step 2:` In the AppDelegate when `didFinishLaunchingWithOptions` call `force` ```swift -dependencies: [ - .package(url: "https://github.com/perseusrealdeal/PerseusDarkMode.git", - .exact("1.1.5")) - ], -``` -- As an Xcode project dependency: +import UIKit +import PerseusDarkMode -`Project in the Navigator > Package Dependencies > Add Package Dependency` +class AppDelegate: UIResponder { var window: UIWindow? } -> ***Using "Exact" with the Version field is strongly recommended.*** +extension AppDelegate: UIApplicationDelegate { -## Step 2: Make DarkMode ready for using + func application(_ application: UIApplication, didFinishLaunchingWithOptions + launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { -### iOS + // Register Settings Bundle + registerSettingsBundle() -Override the following method of the first screen to let Perseus know that system's appearance changed: + // Init the app's window + window = UIWindow(frame: UIScreen.main.bounds) + + // Give it a root view for the first screen + window!.rootViewController = MainViewController.storyboardInstance() + window!.makeKeyAndVisible() + + DarkModeAgent.force(DarkModeUserChoice) + + return true + } + + func applicationDidBecomeActive(_ application: UIApplication) { + + // Actualize Dark Mode style to Settings Bundle + if let choice = DarkModeAgent.isDarkModeSettingsKeyChanged() { + DarkModeAgent.force(choice) + } + } +} + +``` + +`Step 3:` Register the MainViewController and process traitCollectionDidChange for DarkMode changes ```swift + +import UIKit +import PerseusDarkMode + class MainViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + DarkModeAgent.register(stakeholder: self, selector: #selector(makeUp)) + } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if #available(iOS 13.0, *) { - AppearanceService.processTraitCollectionDidChange(previousTraitCollection) + DarkModeAgent.processTraitCollectionDidChange(previousTraitCollection) } } + + @objc private func makeUp() { + + // Runs every time if Dark Mode changes. + // The current DarkMode value is reliable here. + + let isDark = DarkMode.style == .dark + let _ = isDark ? "It's dark" : "No dark" + + } } + ``` -Also, if Dark Mode is released with Settings bundle put the statements into the app's delegate: +# Usage + +## Force Dark Mode + +> The Dark Mode of your app can be easely forced in `.on`, `.off` or `.auto` just call method `force` of DarkModeAgent like this. ```swift -extension AppDelegate: UIApplicationDelegate { - func applicationDidBecomeActive(_ application: UIApplication) { +DarkModeAgent.force(.auto) // That's all. - // Update Dark Mode from Settings - if let choice = isDarkModeSettingsChanged() { - // Change Dark Mode value in Perseus Dark Mode library - AppearanceService.DarkModeUserChoice = choice - // Update appearance in accoring with changed Dark Mode Style - AppearanceService.makeUp() - } - } -} ``` -Used functions are distributed via standalone file [`DarkModeSwitching.swift`](https://gist.github.com/perseusrealdeal/11b1bab47f13134832b859f49d9af706). -### iOS and macOS +The `force` will change the appearance of your app immediately including system components and will make run all custom DarkMode code `makeUp()`. + +## Get awared of DarkMode Changes + +> To declare custom DarkMode sensitive code that runs every time if DarkMode Changes register the object or creat a DarkMode trigger: -Call the method `AppearanceService.makeUp()` with the app's delegate if appearance changing is going to take place: +`Use Case -` Register an object to be notified on changes ```swift -extension AppDelegate: UIApplicationDelegate { - func application(_ application: UIApplication, didFinishLaunchingWithOptions - launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - - // ... code - - // Call AppearanceService.makeUp() method if AppearanceService.register(:, :) - // is taken into account - AppearanceService.makeUp() +class DarkModeSensitiveObject { + + init() { + DarkModeAgent.register(stakeholder: self, selector: #selector(makeUp)) + } - // ... otherwise call AppearanceService.recalculateStyleIfNeeded() - // to load DarkMode.style from user defaults - // AppearanceService.recalculateStyleIfNeeded() + @objc private func makeUp() { + // Runs evary time if Dark Mode changes. } } + ``` -Copy the file [`DarkModeSwitching.swift`](https://gist.github.com/perseusrealdeal/11b1bab47f13134832b859f49d9af706) into a host project for having fun with manual changing Dark Mode value. -# Usage +`Use Case -` Creat a DarkMode trigger and give it an action -Each time if Dark Mode changed the mentioned method `#selector(makeUp)` called, but registering is required: ```swift -class MainViewController: UIViewController { - // At any view controller where changing is required +class DarkModeSensitiveObject { - override func viewDidLoad() { - super.viewDidLoad() + private var theDarknessTrigger = DarkModeObserver() - AppearanceService.register(stakeholder: self, selector: #selector(makeUp)) + init() { + theDarnessTrigger.action = { _ in + self.makeUp() + } } - @objc private func makeUp() { - print("^_^ \(AppearanceService.DarkModeUserChoice)" - - switch DarkMode.style { - case .light: - // make drawings for light mode - break - case .dark: - // make drawings for dark mode - break - } + private func makeUp() { + // Runs evary time if Dark Mode changes. } } + ``` -There is another way to be notified of Dark Mode—KVO. +## React to DarkMode Changes + +`Use Case -` Custom DarkMode Sensitive Color -> [`DarkModeImageView`](https://github.com/perseusrealdeal/PerseusUISystemKit/blob/master/Sources/PerseusUISystemKit/Classes/DarkModeImageView.swift) class is an expressive sample of Dark Mode KVO usage for both macOS and iOS as well. +```swift + +import PerseusDarkMode + +#if canImport(UIKit) +import UIKit +#elseif canImport(Cocoa) +import Cocoa +#endif + +#if os(iOS) +public typealias Color = UIColor +#elseif os(macOS) +public typealias Color = NSColor +#endif + +public func rgba255(_ red: CGFloat, + _ green: CGFloat, + _ blue: CGFloat, + _ alpha: CGFloat = 1.0) -> Color { + return Color(red: red/255, green: green/255, blue: blue/255, alpha: alpha) +} + +extension Color { + public static var customRed: Color { + return DarkModeAgent.shared.style == .light ? + rgba255(255, 59, 48) : rgba255(255, 69, 58) + } +} + +``` + +> Use Custom DarkMode sensitive color. + +```swift + +// Runs every time if the DarkMode changes. Use KVO (DarkModeObserver) or be registered by DarkModeAgent. +@objc private func makeUp() { + self.backgroundColor = .customRed +} + +``` + +# Points taken into account + +- Preconfigured Swift Package manifest [Package.swift](/Package.swift) +- Preconfigured SwiftLint config [.swiftlint.yml](/.swiftlint.yml) +- Preconfigured SwiftLint CI [swiftlint.yml](/.github/workflows/swiftlint.yml) +- Preconfigured GitHub config [.gitignore](/.gitignore) +- Preconfigured GitHub CI [main.yml](/.github/workflows/main.yml) # License MIT -Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. +Copyright © 7530 - 7533 Mikhail A. Zhigulin of Novosibirsk
+Copyright © 7533 PerseusRealDeal - The year starts from the creation of the world according to a Slavic calendar. -- September, the 1st of Slavic year. +- September, the 1st of Slavic year. It means that "Sep 01, 2024" is the beginning of 7533. [LICENSE](/LICENSE) for details. -# Author +## Credits + + + + + + + + + + + + + + + + + + + + + + +
Balance and Controlkept byMikhail A. Zhigulin
Source Codewritten byMikhail A. Zhigulin
Documentationprepared byMikhail A. Zhigulin
Product Approbationtested byMikhail A. Zhigulin
+ +- Language support: [Reverso](https://www.reverso.net/) +- Git clients: [SmartGit](https://syntevo.com/) and [GitHub Desktop](https://github.com/apps/desktop) -> `PerseusDarkMode` was written at Novosibirsk by Mikhail Zhigulin i.e. me, mzhigulin@gmail.com. +# Author -> Mostly I'd like thank my lovely parents for supporting me in all my ways. +> Mikhail A. Zhigulin of Novosibirsk. diff --git a/Sources/PerseusDarkMode/AppearanceService/AppearanceService.swift b/Sources/PerseusDarkMode/AppearanceService/AppearanceService.swift deleted file mode 100644 index be16d03..0000000 --- a/Sources/PerseusDarkMode/AppearanceService/AppearanceService.swift +++ /dev/null @@ -1,291 +0,0 @@ -// -// AppearanceService.swift -// PerseusDarkMode -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// -// swiftlint:disable file_length -// - -#if canImport(UIKit) -import UIKit -#elseif canImport(Cocoa) -import Cocoa -#endif - -#if os(iOS) -public typealias Responder = UIResponder -#elseif os(macOS) -public typealias Responder = NSResponder -#endif - -/// Name of make up notification. -public extension Notification.Name { - static let MakeAppearanceUpNotification = Notification.Name("MakeAppearanceUpNotification") -#if os(macOS) - static let AppleInterfaceThemeChangedNotification = - Notification.Name("AppleInterfaceThemeChangedNotification") -#endif -} - -// swiftlint:disable identifier_name - -/// Dark Mode placed to to be accessed from any screen object -/// of iOS (Mac Catalyst) or macOS (Cocoa). -public extension Responder { - var DarkMode: DarkModeProtocol { return AppearanceService.shared } -} -// swiftlint:enable identifier_name - -/// Represents service giving a control of the app's appearance. -/// -/// - This service is dedicated to handle Dark Mode changing. -public class AppearanceService { - - // MARK: - Singleton - - /// Shared Dark Mode. - public static var shared: DarkMode = { _ = it; return DarkMode() }() - - private(set) static var it = { AppearanceService() }() - private init() { - log.message("[\(type(of: self))].\(#function)") -#if os(macOS) - AppearanceService.distributedNCenter.addObserver( - self, - selector: #selector(modeChanged), - name: .AppleInterfaceThemeChangedNotification, - object: nil - ) -#endif - } - -#if os(macOS) - @objc internal func modeChanged() { - if #available(macOS 10.14, *) { - AppearanceService.processAppearanceOSDidChange() - } - } - - @available(macOS 10.14, *) - public static var defaultDarkAppearanceOS: NSAppearance.Name = .darkAqua - public static var defaultLightAppearanceOS: NSAppearance.Name = .aqua -#endif - - /// TRUE if Appearance.makeUp once called otherwise FALSE. - /// - /// Value is false by default and changed only once - /// when Appearance.makeUp called for the first time, then always true in run time. - public static var isEnabled: Bool { return hidden_isEnabled } - -#if DEBUG && os(macOS) - /// Used for mocking DistributedNotificationCenter in unit testing. - public static var distributedNCenter: NotificationCenterProtocol = - DistributedNotificationCenter.default -#elseif os(macOS) - /// Default Distributed NotificationCenter. - public static var distributedNCenter = DistributedNotificationCenter.default -#endif - -#if DEBUG // Isolated for unit testing - /// Used for mocking NotificationCenter in unit testing. - public static var nCenter: NotificationCenterProtocol = NotificationCenter.default - /// Used for mocking UserDefaults in unit testing. - public static var ud: UserDefaultsProtocol = UserDefaults.standard -#else - /// Default NotificationCenter. - public static var nCenter = NotificationCenter.default - /// Default UserDefaults. - public static var ud = UserDefaults.standard -#endif - - /// User choice for Dark Mode inside the app. - /// - /// The service keeps the value in UserDefaults. - /// It effects DarkMode.StyleObservable on every change. - public static var DarkModeUserChoice: DarkModeOption { - get { - // Load enum Int value - - let rawValue = ud.valueExists(forKey: DARK_MODE_USER_CHOICE_KEY) ? - ud.integer(forKey: DARK_MODE_USER_CHOICE_KEY) : - DARK_MODE_USER_CHOICE_DEFAULT.rawValue - - // Try to cast Int value to enum - - if let result = DarkModeOption.init(rawValue: rawValue) { return result } - - return DARK_MODE_USER_CHOICE_DEFAULT - } - set { - ud.setValue(newValue.rawValue, forKey: DARK_MODE_USER_CHOICE_KEY) - - // Used for KVO to immediately notify a change has happened - recalculateStyleIfNeeded() - } - } - - // MARK: - Public API: register stakeholder - - /// Registers stakeholders of Dark Mode. - /// - Parameters: - /// - stakeholder: Stakeholder of Dark Mode. - /// - selector: Point to pass a control of Dark Mode. - public static func register(stakeholder: Any, selector: Selector) { - nCenter.addObserver(stakeholder, - selector: selector, - name: .MakeAppearanceUpNotification, - object: nil) - } - - // MARK: - Public API: make the app's appearance up - - /// Calls all registered stakeholders for making up. - /// - /// First time should be called when didFinishLaunching happens and then every - /// time when DarkModeUserChoice changes. - public static func makeUp() { - hidden_isEnabled = true - hidden_changeManually = true - - if #available(iOS 13.0, macOS 10.14, *) { overrideUserInterfaceStyleIfNeeded() } - - recalculateStyleIfNeeded() - - nCenter.post(name: .MakeAppearanceUpNotification, object: nil) - hidden_changeManually = false - } - -#if os(iOS) - /// Puts the app's Dark Mode in line with System Appearance Mode. - /// - /// Should be called when user taggles System Appearance Mode in Settings app. - /// Call it in override func traitCollectionDidChange in the main screen. - /// - /// - Parameter previousTraitCollection: Used to extract userInterfaceStyle value. - @available(iOS 13.0, *) - public static func processTraitCollectionDidChange( - _ previousTraitCollection: UITraitCollection?) { - if hidden_changeManually { return } - - guard let previousSystemStyle = previousTraitCollection?.userInterfaceStyle, - previousSystemStyle.rawValue != shared.systemStyle.rawValue - else { return } - - hidden_systemCalledMakeUp() - } -#elseif os(macOS) - @available(macOS 10.14, *) - internal static func processAppearanceOSDidChange() { - if hidden_changeManually { return } - hidden_systemCalledMakeUp() - } -#endif - - // MARK: - Implementation helpers, privates and internals - - /// Used to make possible applying Black White approach in Screen design. - private(set) static var hidden_isEnabled: Bool = - false { willSet { if newValue == false { return }}} - - /// Used to reduce double calling of traitCollectionDidChange. - internal static var hidden_changeManually: Bool = false - - /// Make up if TraitCollectionDidChange. - internal static func hidden_systemCalledMakeUp() { - if hidden_changeManually { return } - - hidden_isEnabled = true - - recalculateStyleIfNeeded() - nCenter.post(name: .MakeAppearanceUpNotification, object: nil) - } - - /// Updates the app's appearance style value. - public static func recalculateStyleIfNeeded() { - let actualStyle = DarkModeDecision.calculate(DarkModeUserChoice, shared.systemStyle) - - if shared.hidden_style != actualStyle { shared.hidden_style = actualStyle } - } - - /// Changes the app's UserInterfaceStyle. - /// - /// It's matter to change the look of system user controls. - @available(iOS 13.0, macOS 10.14, *) - internal static func overrideUserInterfaceStyleIfNeeded() { - if hidden_changeManually == false { return } -#if os(iOS) && compiler(>=5) - guard let keyWindow = UIWindow.key else { return } - var overrideStyle: UIUserInterfaceStyle = .unspecified - - switch DarkModeUserChoice { - case .auto: - overrideStyle = .unspecified - - case .on: - overrideStyle = .dark - - case .off: - overrideStyle = .light - } - - keyWindow.overrideUserInterfaceStyle = overrideStyle - -#elseif os(macOS) - switch DarkModeUserChoice { - case .auto: - NSApplication.shared.appearance = nil - case .on: - NSApplication.shared.appearance = - NSAppearance(named: AppearanceService.defaultDarkAppearanceOS) - case .off: - NSApplication.shared.appearance = - NSAppearance(named: AppearanceService.defaultLightAppearanceOS) - } -#endif - } -} - -// Local helpers - -extension UserDefaults { - public func valueExists(forKey key: String) -> Bool { - return object(forKey: key) != nil - } -} - -#if os(iOS) -extension UIWindow { - static var key: UIWindow? { - if #available(iOS 13, *) { - return UIApplication.shared.windows.first { $0.isKeyWindow } - } else { - return UIApplication.shared.keyWindow - } - } -} -#endif - -// MARK: - Protocols used for unit testing - -public protocol NotificationCenterProtocol { - func addObserver(_ observer: Any, - selector aSelector: Selector, - name aName: NSNotification.Name?, - object anObject: Any?) - func post(name aName: NSNotification.Name, object anObject: Any?) -} - -public protocol UserDefaultsProtocol { - func valueExists(forKey key: String) -> Bool - func integer(forKey defaultName: String) -> Int - func setValue(_ value: Any?, forKey key: String) -} - -extension UserDefaults: UserDefaultsProtocol { } -extension NotificationCenter: NotificationCenterProtocol { } diff --git a/Sources/PerseusDarkMode/AppearanceService/AppearanceStyle.swift b/Sources/PerseusDarkMode/AppearanceService/AppearanceStyle.swift deleted file mode 100644 index 3d98867..0000000 --- a/Sources/PerseusDarkMode/AppearanceService/AppearanceStyle.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// AppearanceStyle.swift -// PerseusDarkMode -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -/// Represents appearance style. -public enum AppearanceStyle: Int, CustomStringConvertible { - - case light = 0 - case dark = 1 - - /// Textual represantation. - public var description: String { - switch self { - case .light: - return ".light" - case .dark: - return ".dark" - } - } -} - -/// Represents system appearance style. -/// -/// Used to bring System Style to early iOS releases. -public enum SystemStyle: Int, CustomStringConvertible { - - case unspecified = 0 - case light = 1 - case dark = 2 - - /// Textual represantation. - public var description: String { - switch self { - case .unspecified: - return ".unspecified" - case .light: - return ".light" - case .dark: - return ".dark" - } - } -} diff --git a/Sources/PerseusDarkMode/CPLStar.swift b/Sources/PerseusDarkMode/CPLStar.swift new file mode 100644 index 0000000..2cd1652 --- /dev/null +++ b/Sources/PerseusDarkMode/CPLStar.swift @@ -0,0 +1,364 @@ +// +// CPLStar.swift +// Version: 1.1.0 +// +// Standalone ConsolePerseusLogger. +// +// +// For iOS and macOS only. Use Stars to adopt for the specifics you need. +// +// DESC: USE LOGGER LIKE A VARIABLE ANYWHERE YOU WANT. +// +// Created by Mikhail Zhigulin in 7531. +// +// Copyright © 7531 - 7533 Mikhail A. Zhigulin of Novosibirsk +// Copyright © 7531 - 7533 PerseusRealDeal +// +// All rights reserved. +// +// +// MIT License +// +// Copyright © 7531 - 7533 Mikhail A. Zhigulin of Novosibirsk +// Copyright © 7531 - 7533 PerseusRealDeal +// +// The year starts from the creation of the world according to a Slavic calendar. +// September, the 1st of Slavic year. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// swiftlint:disable file_length +// + +import Foundation +import os + +// swiftlint:disable type_name +typealias log = PerseusLogger // In SPM package should be not public except TheOne. +// swiftlint:enable type_name + +public typealias ConsoleObject = (subsystem: String, category: String) + +public let CONSOLE_APP_SUBSYSTEM_DEFAULT = "Perseus" +public let CONSOLE_APP_CATEGORY_DEFAULT = "Logger" + +public class PerseusLogger { + + // MARK: - Specifics + + public enum Status { + case on + case off + } + + public enum Output { + case standard // In Use: Swift.print(""). + case consoleapp + // case outputfile + } + + public enum Level: Int, CustomStringConvertible { + + public var description: String { + switch self { + case .debug: + return "DEBUG" + case .info: + return "INFO" + case .notice: + return "NOTE" + case .error: + return "ERROR" + case .fault: + return "FAULT" + } + } + + public var tag: String { + switch self { + case .debug: + return "[DEBUG]" + case .info: + return "[INFO ]" + case .notice: + return "[NOTE ]" + case .error: + return "[ERROR]" + case .fault: + return "[FAULT]" + } + } + + case debug = 5 + case info = 4 + case notice = 3 + case error = 2 + case fault = 1 + } + + public enum TimeMultiply { + // case millisecond // -3. + // case microsecond // -6. + case nanosecond // -9. + } + + public enum MessageFormat { // [TYPE] [DATE] [TIME] message, file: #, line: # + + case short + // marks true, time false, directives false + // [DEBUG] message + + // marks true, time true, directives false + // [DEBUG] [2025:04:17] [20:31:53:630594968] message + + // marks true, time false, directives true + // [DEBUG] message, file: File.swift, line: 29 + + // marks true, time true, directives true + // [DEBUG] [2025:04:17] [20:31:53:630918979] message, file: File.swift, line: 29 + + // marks false, directives true + // message, file: File.swift, line: 29 + + // marks false, directives false + // message + + case full + // [DEBUG] [2025:04:17] [20:31:53:630918979] message, file: File.swift, line: 29 + + case textonly + // message + } + + // MARK: - Properties + +#if DEBUG + public static var turned = Status.on + public static var level = Level.debug + public static var output = Output.standard +#else + public static var turned = Status.off + public static var level = Level.notice + public static var output = Output.consoleapp +#endif + + public static var subsecond = TimeMultiply.nanosecond + public static var format = MessageFormat.short + + public static var marks = true // Controls tags [TYPE] [DATE] [TIME]. + public static var time = false // If also and marks true adds [DATE] [TIME] to message. + + public static var directives = false // File# and Line# in message. + +#if targetEnvironment(simulator) + public static var debugIsInfo = true // Shows DEBUG message as INFO in macOS Console.app. +#endif + + public static var logObject: ConsoleObject? { + didSet { + + guard let obj = logObject else { + + if #available(iOS 14.0, macOS 11.0, *) { + consoleLogger = nil + } + + consoleOSLog = nil + + return + } + + if #available(iOS 14.0, macOS 11.0, *) { + consoleLogger = Logger(subsystem: obj.subsystem, category: obj.category) + } else { + consoleOSLog = OSLog(subsystem: obj.subsystem, category: obj.category) + } + } + } + + public static var localTime: String { + return getLocalTime() + } + + // MARK: - Internals + + @available(iOS 14.0, macOS 11.0, *) + private(set) static var consoleLogger: Logger? + private(set) static var consoleOSLog: OSLog? + + // MARK: - Contract + + public static func message(_ text: @autoclosure () -> String, + _ type: Level = .debug, + _ file: StaticString = #file, + _ line: UInt = #line) { + + guard turned == .on, type.rawValue <= level.rawValue else { return } + + var message = "" + + // Path. + + let withDirectives = (format == .full) ? true : (directives && (format != .textonly)) + + if withDirectives { + let fileName = (file.description as NSString).lastPathComponent + message = "\(text()), file: \(fileName), line: \(line)" + } else { + message = "\(text())" + } + + // Time. + + let isTimed = (format == .full) ? true : marks && time && (format != .textonly) + message = isTimed ? "\(getLocalTime()) \(message)" : message + + // Type. + + let isTyped = (format == .full) ? true : marks && (format != .textonly) + message = isTyped ? "\(type.tag) \(message)" : message + + // Print. + + print(message, type) + } + + // MARK: - Implementation + + // swiftlint:disable:next cyclomatic_complexity + private static func print(_ text: String, _ type: Level) { + + let message = text + + if output == .standard { + + Swift.print(message) // DispatchQueue.main.async { print(message) } + + } else if output == .consoleapp { + + if #available(iOS 14.0, macOS 11.0, *) { + + let logger = consoleLogger ?? Logger(subsystem: CONSOLE_APP_SUBSYSTEM_DEFAULT, + category: CONSOLE_APP_CATEGORY_DEFAULT) + + switch type { + case .debug: +#if targetEnvironment(simulator) + if debugIsInfo { + logger.info("\(message, privacy: .public)") + } else { + logger.debug("\(message, privacy: .public)") + } +#else + logger.debug("\(message, privacy: .public)") +#endif + case .info: + logger.info("\(message, privacy: .public)") + case .notice: + logger.notice("\(message, privacy: .public)") + case .error: + logger.error("\(message, privacy: .public)") + case .fault: + logger.fault("\(message, privacy: .public)") + } + + return + } + + let consoleLog = consoleOSLog ?? OSLog(subsystem: CONSOLE_APP_SUBSYSTEM_DEFAULT, + category: CONSOLE_APP_CATEGORY_DEFAULT) + + switch type { + case .debug: +#if targetEnvironment(simulator) + if debugIsInfo { + os_log("%{public}@", log: consoleLog, type: .info, message) + } else { + os_log("%{public}@", log: consoleLog, type: .debug, message) + } +#else + os_log("%{public}@", log: consoleLog, type: .debug, message) +#endif + case .info: + os_log("%{public}@", log: consoleLog, type: .info, message) + case .notice: + os_log("%{public}@", log: consoleLog, type: .default, message) + case .error: + os_log("%{public}@", log: consoleLog, type: .error, message) + case .fault: + os_log("%{public}@", log: consoleLog, type: .fault, message) + } + } + } + + private static func getLocalTime() -> String { + + guard let timezone = TimeZone(secondsFromGMT: 0) else { return "TIME" } + + var calendar = Calendar.current + + calendar.timeZone = timezone + calendar.locale = Locale(identifier: "en_US_POSIX") + + let current = Date(timeIntervalSince1970: (Date().timeIntervalSince1970 + + Double(TimeZone.current.secondsFromGMT()))) + + let details: Set = + [ + .year, .month, .day, .hour, .minute, .second, .nanosecond + ] + + let components = calendar.dateComponents(details, from: current) + + // Parse date. + + guard + let year = components.year, + let month = components.month?.inTime, + let day = components.day?.inTime else { return "TIME" } + + let date = "[\(year)-\(month)-\(day)]" + + // Parse time. + + guard + let hour = components.hour?.inTime, // Always in 24-hour. + let minute = components.minute?.inTime, + let second = components.second?.inTime, + let subsecond = components.nanosecond?.multiply else { return "TIME" } + + let time = "[\(hour):\(minute):\(second):\(subsecond)]" + + return "\(date) \(time)" + } +} + +// MARK: - Helpers + +private extension Int { + + var inTime: String { + guard self >= 0, self <= 9 else { return String(self) } + return "0\(self)" + } + + var multiply: String { + return String(self) + } +} diff --git a/Sources/PerseusDarkMode/DarkMode/Constants.swift b/Sources/PerseusDarkMode/DarkMode/Constants.swift deleted file mode 100644 index 858835e..0000000 --- a/Sources/PerseusDarkMode/DarkMode/Constants.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Constants.swift -// PerseusDarkMode -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -import Foundation - -/// UserDefaults Key for AppearanceService.DarkModeUserChoice variable. -public let DARK_MODE_USER_CHOICE_KEY = "DarkModeUserChoiceOptionKey" - -/// Default value for AppearanceService.DarkModeUserChoice variable. -public let DARK_MODE_USER_CHOICE_DEFAULT = DarkModeOption.auto - -/// Default valur for AppearanceService.shared.Style. -public let DARK_MODE_STYLE_DEFAULT = AppearanceStyle.light - -/// Name of DarkMode.StyleObservable variable used in KVO -public let OBSERVERED_VARIABLE_NAME = "styleObservable" diff --git a/Sources/PerseusDarkMode/DarkMode/DarkMode.swift b/Sources/PerseusDarkMode/DarkMode/DarkMode.swift deleted file mode 100644 index 8df1656..0000000 --- a/Sources/PerseusDarkMode/DarkMode/DarkMode.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// DarkMode.swift -// PerseusDarkMode -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -#if canImport(UIKit) -import UIKit -#elseif canImport(Cocoa) -import Cocoa -#endif - -/// Represents Dark Mode and contains the app's appearance style. -/// -/// - KVO technique can be used to be notified on the app's appearance style changed event. -/// - Use StyleObservable varible to create an observer. -public class DarkMode: NSObject { - - // MARK: - The App's current Appearance Style - - /// The app's current appearance style. - public var style: AppearanceStyle { return hidden_style } - - // MARK: - Observable Appearance Style Value (Using Key-Value Observing) - - /// Shows the same value of Style but observable. - /// - /// Takes place because swift doesn't support observing enums. - @objc public dynamic var styleObservable: Int = DARK_MODE_STYLE_DEFAULT.rawValue - - // MARK: - System's Appearance Style - - /// The app's current system appearance style. - public var systemStyle: SystemStyle { - if #available(iOS 13.0, macOS 10.14, *) { -#if os(iOS) - guard let keyWindow = UIWindow.key else { return .unspecified } - - switch keyWindow.traitCollection.userInterfaceStyle { - case .unspecified: - return .unspecified - case .light: - return .light - case .dark: - return .dark - - @unknown default: - return .unspecified - } -#elseif os(macOS) - if let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle"), - isDark == "Dark" { - return .dark - } else { - return .light - } -#endif - } else { - return .unspecified - } - } - - internal var hidden_style: AppearanceStyle = DARK_MODE_STYLE_DEFAULT { - didSet { styleObservable = style.rawValue } - } -} - -// MARK: - Protocols used for unit testing - -public protocol DarkModeProtocol { - var style: AppearanceStyle { get } - var systemStyle: SystemStyle { get } - var styleObservable: Int { get } -} - -extension DarkMode: DarkModeProtocol { } diff --git a/Sources/PerseusDarkMode/DarkMode/DarkModeDecision.swift b/Sources/PerseusDarkMode/DarkMode/DarkModeDecision.swift deleted file mode 100644 index b37ceb4..0000000 --- a/Sources/PerseusDarkMode/DarkMode/DarkModeDecision.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// DarkModeDecision.swift -// PerseusDarkMode -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -/// Makes a calculation of the app's appearance style. -public class DarkModeDecision { - - private init() { } - - // MARK: - Calculating Dark Mode decision - - /// Calculates the current appearance style of the app. - /// - /// Dark Mode decision-making: - /// - /// | DarkModeOption - /// -------------+----------------------- - /// SystemStyle | auto | on | off - /// -------------+---------+------+------ - /// .unspecified | default | dark | light - /// .light | light | dark | light - /// .dark | dark | dark | light - /// - public class func calculate(_ userChoice: DarkModeOption, - _ systemStyle: SystemStyle) -> AppearanceStyle { - // Calculate outputs - - if (systemStyle == .unspecified) && (userChoice == .auto) { - return DARK_MODE_STYLE_DEFAULT - } - if (systemStyle == .unspecified) && (userChoice == .on) { return .dark } - if (systemStyle == .unspecified) && (userChoice == .off) { return .light } - - if (systemStyle == .light) && (userChoice == .auto) { return .light } - if (systemStyle == .light) && (userChoice == .on) { return .dark } - if (systemStyle == .light) && (userChoice == .off) { return .light } - - if (systemStyle == .dark) && (userChoice == .auto) { return .dark } - if (systemStyle == .dark) && (userChoice == .on) { return .dark } - if (systemStyle == .dark) && (userChoice == .off) { return .light } - - // Output default value if somethings goes out of the decision table - - return DARK_MODE_STYLE_DEFAULT - } -} diff --git a/Sources/PerseusDarkMode/DarkMode/DarkModeObserver.swift b/Sources/PerseusDarkMode/DarkMode/DarkModeObserver.swift deleted file mode 100644 index d26c7ff..0000000 --- a/Sources/PerseusDarkMode/DarkMode/DarkModeObserver.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// DarkModeObserver.swift -// PerseusDarkMode -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// -// swiftlint:disable block_based_kvo -// - -#if canImport(UIKit) -import UIKit -#elseif canImport(Cocoa) -import Cocoa -#endif - -/// Responsible for making run the code if the app's appearance style changed. -/// -/// It connects to shared Dark Mode instance using appearance service. -/// Also, it takes action every time when the app's appearance style changed. -/// -/// The action can be specified with initialization as the passed closue -/// and after initialization by assigning the action property as well. -public class DarkModeObserver: NSObject { - /// Closure to perform if the app's appearance style changed. - public var action: ((_ newStyle: AppearanceStyle) -> Void)? - - /// The reference of the object to be obsevered. - private(set) var objectToObserve = AppearanceService.shared - - /// Initializer by default. - /// - /// It creates the instance with no action specified if the app's appearance style changed. - /// - /// Then, give it a closure to run the code if the app's appearance style changed. - public override init() { - super.init() - - objectToObserve.addObserver(self, - forKeyPath: OBSERVERED_VARIABLE_NAME, - options: .new, - context: nil) - } - - /// Initializer with parameters. - /// - /// Pass a closure to specify the action to be taken if the app's appearance style changed. - public init(_ action: @escaping ((_ newStyle: AppearanceStyle) -> Void)) { - super.init() - - self.action = action - objectToObserve.addObserver(self, - forKeyPath: OBSERVERED_VARIABLE_NAME, - options: .new, - context: nil) - } - - /// Takes action every time when Style changes happens. - public override func observeValue(forKeyPath keyPath: String?, - of object: Any?, - change: [NSKeyValueChangeKey: Any]?, - context: UnsafeMutableRawPointer?) { - guard - keyPath == OBSERVERED_VARIABLE_NAME, - let style = change?[.newKey], - let styleRawValue = style as? Int, - let newStyle = AppearanceStyle.init(rawValue: styleRawValue) - else { return } - - action?(newStyle) - } - - deinit { - objectToObserve.removeObserver(self, forKeyPath: OBSERVERED_VARIABLE_NAME) - } -} diff --git a/Sources/PerseusDarkMode/DarkMode/DarkModeOption.swift b/Sources/PerseusDarkMode/DarkMode/DarkModeOption.swift deleted file mode 100644 index 670c7f9..0000000 --- a/Sources/PerseusDarkMode/DarkMode/DarkModeOption.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// DarkModeOption.swift -// PerseusDarkMode -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -import Foundation - -/// Represents a list of possible values for Dark Mode option. -/// -/// - AUTO means the choice is made by System. -/// - ON means the app's appearance style should be Dark at any case. -/// - OFF means the app's appearance style should be Light at any case. -public enum DarkModeOption: Int, CustomStringConvertible { - - case auto = 0 - case on = 1 - case off = 2 - - /// Textual representation of the current value of the option. - public var description: String { - switch self { - case .auto: - return ".auto" - case .on: - return ".on" - case .off: - return ".off" - } - } -} diff --git a/Sources/PerseusDarkMode/PerseusLogger.swift b/Sources/PerseusDarkMode/PerseusLogger.swift deleted file mode 100644 index 70c1f6a..0000000 --- a/Sources/PerseusDarkMode/PerseusLogger.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// PerseusLogger.swift -// PerseusRealDeal -// -// Created by Mikhail Zhigulin in 7531. -// -// Copyright © 7531 Mikhail Zhigulin of Novosibirsk. -// Copyright © 7531 PerseusRealDeal. -// All rights reserved. -// -// -// MIT License -// -// Copyright © 7531 Mikhail Zhigulin of Novosibirsk -// Copyright © 7531 PerseusRealDeal -// -// The year starts from the creation of the world according to a Slavic calendar. -// September, the 1st of Slavic year. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notices and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -// DESC: USE LOGGER LIKE A VARIABLE ANYWHERE YOU WANT -// -// By default logger is turned on in DEBUG, but it's off in RELEASE. -// -// log.message("[\(type(of: self))].\(#function)") -// - -/* To disable debug messaging of the module use the following statements in the start point. - - import class OpenWeatherFreeClient.PerseusLogger - typealias FreeClientLogger = OpenWeatherFreeClient.PerseusLogger - - FreeClientLogger.turned = .off - - */ - -import Foundation - -// swiftlint:disable type_name -typealias log = PerseusLogger -// swiftlint:enable type_name - -public class PerseusLogger { - - public enum Status { - case on - case off - } - - public enum Level: Int, CustomStringConvertible { - - public var description: String { - switch self { - case .info: - return "INFO" - case .debug: - return "DEBUG" - case .error: - return "ERROR" - } - } - - case info = 3 - case debug = 2 // Default. - case error = 1 - } - - #if DEBUG - public static var turned = Status.on - #else - public static var turned = Status.off - #endif - - public static var level = Level.debug - public static var short = true - - public static func message(_ text: @autoclosure () -> String, - _ type: Level = .debug, - _ file: StaticString = #file, - _ line: UInt = #line) { - - guard turned == .on, type.rawValue <= level.rawValue else { return } - - var message = "" - - if short { - message = "\(type): \(text())" - } else { - let fileName = (file.description as NSString).lastPathComponent - message = "\(type): \(text()), file: \(fileName), line: \(line)" - } - - print(message) // DispatchQueue.main.async { print(message) } - } -} diff --git a/Sources/PerseusDarkMode/TheDarkness.swift b/Sources/PerseusDarkMode/TheDarkness.swift new file mode 100644 index 0000000..ff035d1 --- /dev/null +++ b/Sources/PerseusDarkMode/TheDarkness.swift @@ -0,0 +1,455 @@ +// +// TheDarkness.swift +// PerseusDarkMode +// +// +// For iOS and macOS only. Use Stars to adopt for the specifics you need. +// +// DESC: THE DARKNESS YOU CAN FORCE. +// +// Created by Mikhail Zhigulin in 7530. +// +// Copyright © 7530 - 7533 Mikhail Zhigulin of Novosibirsk +// Copyright © 7533 PerseusRealDeal +// +// All rights reserved. +// +// +// MIT License +// +// Copyright © 7530 - 7533 Mikhail A. Zhigulin of Novosibirsk +// Copyright © 7533 PerseusRealDeal +// +// The year starts from the creation of the world according to a Slavic calendar. +// September, the 1st of Slavic year. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// swiftlint:disable file_length +// + +#if canImport(UIKit) +import UIKit +#elseif canImport(Cocoa) +import Cocoa +#endif + +public let APPEARANCE_DEFAULT = AppearanceStyle.light + +public let DARK_MODE_USER_CHOICE_KEY = "DarkModeUserChoiceOptionKey" +public let DARK_MODE_USER_CHOICE_DEFAULT = DarkModeOption.auto + +#if os(iOS) +public let DARK_MODE_SETTINGS_KEY = "DarkModeSettingsKey" +#endif + +#if os(macOS) +public var DARK_APPEARANCE_DEFAULT_IN_USE: NSAppearance { + guard let darkAppearanceOS = DARK_APPEARANCE_DEFAULT else { + if #available(macOS 10.14, *) { + return NSAppearance(named: .darkAqua)! + } // For HighSierra. + return NSAppearance(named: .vibrantDark)! + } + return darkAppearanceOS +} +public var DARK_APPEARANCE_DEFAULT: NSAppearance? +public var LIGHT_APPEARANCE_DEFAULT_IN_USE = NSAppearance(named: .aqua)! +#endif + +public extension Notification.Name { + static let MakeAppearanceUpNotification = + Notification.Name("MakeAppearanceUpNotification") +#if os(macOS) + static let AppleInterfaceThemeChangedNotification = + Notification.Name("AppleInterfaceThemeChangedNotification") +#endif +} + +// swiftlint:disable identifier_name +public extension NSObject { + var DarkMode: DarkMode { return DarkModeAgent.shared } + var DarkModeUserChoice: DarkModeOption { return DarkModeAgent.DarkModeUserChoice } +} +// swiftlint:enable identifier_name + +public enum AppearanceStyle: Int, CustomStringConvertible { + + case light = 0 + case dark = 1 + + public var description: String { + switch self { + case .light: + return ".light" + case .dark: + return ".dark" + } + } +} + +public enum DarkModeOption: Int, CustomStringConvertible { + + case auto = 0 + case on = 1 + case off = 2 + + public var description: String { + switch self { + case .auto: + return ".auto" + case .on: + return ".on" + case .off: + return ".off" + } + } +} + +public class DarkMode: NSObject { + + public var style: AppearanceStyle { return appearance } + + @objc public dynamic var styleObservable: Int = APPEARANCE_DEFAULT.rawValue + + internal var appearance: AppearanceStyle = APPEARANCE_DEFAULT { + didSet { styleObservable = style.rawValue } + } +} + +public class DarkModeAgent { + + // MARK: - Properties + + public static var shared: DarkMode = { _ = instance; return DarkMode() }() + public static var DarkModeUserChoice: DarkModeOption { + return userChoice + } + + // MARK: - Internals + + private static var userChoice: DarkModeOption { + get { + let rawValue = ud.valueExists(forKey: DARK_MODE_USER_CHOICE_KEY) ? + ud.integer(forKey: DARK_MODE_USER_CHOICE_KEY) : + DARK_MODE_USER_CHOICE_DEFAULT.rawValue + + if let result = DarkModeOption.init(rawValue: rawValue) { return result } + return DARK_MODE_USER_CHOICE_DEFAULT + } + set { + ud.setValue(newValue.rawValue, forKey: DARK_MODE_USER_CHOICE_KEY) +#if os(iOS) + // Set DarkMode option for Settings bundle + ud.setValue(newValue.rawValue, forKey: DARK_MODE_SETTINGS_KEY) +#endif + } + } + +#if os(macOS) + private static var distributedNCenter = DistributedNotificationCenter.default +#endif + private static var nCenter = NotificationCenter.default + private static var ud = UserDefaults.standard + + private var observation: NSKeyValueObservation? + + // MARK: - Singletone + + private static var instance = { DarkModeAgent() }() + private init() { + log.message("[\(type(of: self))].\(#function)", .info) +#if os(macOS) + if #available(macOS 10.14, *) { + DarkModeAgent.distributedNCenter.addObserver( + self, + selector: #selector(processAppleInterfaceThemeChanged), + name: .AppleInterfaceThemeChangedNotification, + object: nil + ) + observation = NSApp.observe(\.effectiveAppearance) { _, _ in + if DarkModeAgent.userChoice == .auto { + + let effectiveAppearance = NSApplication.shared.effectiveAppearance + let wanted = self.getRequired(from: effectiveAppearance) + + DarkModeAgent.shared.appearance = wanted + self.notifyAllRegistered() + } + } + } // For HighSierra there is no need in observation cos' system style never change. +#endif + } + + // MARK: - Contract + + public static func register(stakeholder: Any, selector: Selector) { + self.nCenter.addObserver(stakeholder, + selector: selector, + name: .MakeAppearanceUpNotification, + object: nil) + } + + public static func force(_ userChoice: DarkModeOption) { + log.message("[\(type(of: self))].\(#function) \(userChoice)", .info) + + DarkModeAgent.userChoice = userChoice + DarkModeAgent.instance.refresh() + +#if os(iOS) + DarkModeAgent.instance.notifyAllRegistered() +#elseif os(macOS) + if #unavailable(macOS 10.14) { // For HighSierra. + DarkModeAgent.instance.notifyAllRegistered() + } else if DarkModeAgent.userChoice != .auto { // If .auto observation notifies change. + DarkModeAgent.instance.notifyAllRegistered() + } +#endif + } + + public static func makeUp() { + DarkModeAgent.instance.notifyAllRegistered() + } + + // MARK: - iOS Contract specifics + +#if os(iOS) + @available(iOS 13.0, *) + public static func processTraitCollectionDidChange(_ previous: UITraitCollection?) { + if let previous = previous?.userInterfaceStyle { + if UIWindow.systemStyle.rawValue != previous.rawValue { + DarkModeAgent.instance.processAppleInterfaceThemeChanged() + } + } + } + + // Returns nil if DarkMode equal to User choice or no value saved otherwise new value. + public static func isDarkModeSettingsKeyChanged() -> DarkModeOption? { + let option = ud.valueExists(forKey: DARK_MODE_SETTINGS_KEY) ? + ud.integer(forKey: DARK_MODE_SETTINGS_KEY) : -1 + + guard option != -1, let settingsDarkMode = DarkModeOption.init(rawValue: option) + else { return nil } + + return settingsDarkMode != userChoice ? settingsDarkMode : nil + } +#endif + + // MARK: - Implementation + + @objc private func processAppleInterfaceThemeChanged() { + refresh() + notifyAllRegistered() + } + + private func refresh() { + let choice = DarkModeAgent.userChoice +#if os(iOS) + let current = UIWindow.systemStyle + let required = makeDecision(current, choice) + + if #available(iOS 13.0, *) { + UIWindow.refreshKeyWindow() + } + + DarkModeAgent.shared.appearance = required +#elseif os(macOS) + if #available(macOS 10.14, *) { + switch choice { + case .auto: + NSApp.appearance = nil + // DarkModeAgent is informed about new appearance by observation. + case .on: + NSApp.appearance = DARK_APPEARANCE_DEFAULT_IN_USE + DarkModeAgent.shared.appearance = .dark + case .off: + NSApp.appearance = LIGHT_APPEARANCE_DEFAULT_IN_USE + DarkModeAgent.shared.appearance = .light + } + } else { // For HighSierra. + DarkModeAgent.shared.appearance = choice == .on ? .dark : .light + } +#endif + } + + private func notifyAllRegistered() { + DarkModeAgent.nCenter.post(name: .MakeAppearanceUpNotification, object: nil) + } + +#if os(macOS) + private func getRequired(from appearance: NSAppearance?) -> AppearanceStyle { + let choice = DarkModeAgent.userChoice + + if #available(macOS 10.14, *) { + guard choice == .auto else { + return choice == .off ? .light : .dark + } + + if let match = appearance?.bestMatch(from: [.darkAqua, .vibrantDark]) { + return [.darkAqua, .vibrantDark].contains(match) ? .dark : .light + } + + return .light + } else { // For HighSierra. + return choice == .off ? .dark : .light + } + } +#endif +} + +// MARK: - Helpers + +extension UserDefaults { + public func valueExists(forKey key: String) -> Bool { + return object(forKey: key) != nil + } +} + +public class DarkModeObserver: NSObject { + + public var action: ((_ newStyle: AppearanceStyle) -> Void)? + + private var objectToObserve = DarkModeAgent.shared + private var observation: NSKeyValueObservation? + + public override init() { + super.init() + setupObservation() + } + + public init(_ action: @escaping ((_ newStyle: AppearanceStyle) -> Void)) { + super.init() + + self.action = action + setupObservation() + } + + private func setupObservation() { + observation = objectToObserve.observe(\.styleObservable) { _, _ in + self.action?(self.objectToObserve.style) + } + } +} + +#if os(iOS) +public enum SystemStyle: Int, CustomStringConvertible { + + case unspecified = 0 + case light = 1 + case dark = 2 + + public var description: String { + switch self { + case .unspecified: + return ".unspecified" + case .light: + return ".light" + case .dark: + return ".dark" + } + } +} + +extension UIWindow { + + static var key: UIWindow? { + if #available(iOS 13, *) { + return UIApplication.shared.windows.first { $0.isKeyWindow } + } else { + return UIApplication.shared.keyWindow + } + } + + static var systemStyle: SystemStyle { + if #available(iOS 13.0, *) { + guard let keyWindow = UIWindow.key else { return .unspecified } + + switch keyWindow.traitCollection.userInterfaceStyle { + case .unspecified: + return .unspecified + case .light: + return .light + case .dark: + return .dark + + @unknown default: + return .unspecified + } + } else { + return .unspecified // For iOS 12.0 and earlier. + } + } +} + +// MARK: - Calculating Dark Mode Required + +/// Calculates the current required appearance style of the app. +/// +/// Dark Mode decision-making: +/// +/// | User +/// -------------+----------------------- +/// System | auto | on | off +/// -------------+---------+------+------ +/// .unspecified | default | dark | light +/// .light | light | dark | light +/// .dark | dark | dark | light +/// +public func makeDecision(_ system: SystemStyle, _ user: DarkModeOption) -> AppearanceStyle { + + if (system == .unspecified) && (user == .auto) { return APPEARANCE_DEFAULT } + if (system == .unspecified) && (user == .on) { return .dark } + if (system == .unspecified) && (user == .off) { return .light } + + if (system == .light) && (user == .auto) { return .light } + if (system == .light) && (user == .on) { return .dark } + if (system == .light) && (user == .off) { return .light } + + if (system == .dark) && (user == .auto) { return .dark } + if (system == .dark) && (user == .on) { return .dark } + if (system == .dark) && (user == .off) { return .light } + + return APPEARANCE_DEFAULT + +} +#endif + +#if os(iOS) && compiler(>=5) +extension UIWindow { + + @available(iOS 13.0, *) + public static func refreshKeyWindow() { + + guard let keyWindow = UIWindow.key else { return } + + var overrideStyle: UIUserInterfaceStyle = .unspecified + + switch DarkModeAgent.DarkModeUserChoice { + case .auto: + overrideStyle = .unspecified + case .on: + overrideStyle = .dark + case .off: + overrideStyle = .light + } + + keyWindow.overrideUserInterfaceStyle = overrideStyle + } +} +#endif diff --git a/SucceedsPostAction.sh b/SucceedsPostAction.sh index 3a15ccd..8a0bc58 100755 --- a/SucceedsPostAction.sh +++ b/SucceedsPostAction.sh @@ -3,13 +3,13 @@ # SucceedsPostAction.sh # PerseusDarkMode # -# Copied and edited by Mikhail Zhigulin in 2022. -# +# Copied and edited by Mikhail A. Zhigulin in 2022. +# CHANGED: Post action output file name. # The MIT License (MIT) # Copyright (c) 2021 Alexandre Colucci, geteimy.com -# Copyright (c) 2022 Mikhail Zhigulin of Novosibirsk +# Copyright (c) 2022 Mikhail A. Zhigulin of Novosibirsk # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to @@ -43,7 +43,7 @@ # # Usage # -# 1. Ensure that the script has the proper permissions, i.e. +# 1. Ensure that the script has the proper permissions, i.e. # run chmod 755 SucceedsPostAction.sh # 2. Launch Xcode 13.0 or later # 3. Open Preferences > Locations and ensure that Command Line Tools @@ -99,7 +99,7 @@ fi # Run swiftlint #------------------------------------------------------------------------------- OUTPUT="${SWIFTLINT_PATH} ${PACKAGE_ROOT_FOLDER}" -OUTPUT_FILE_PATH="${PACKAGE_ROOT_FOLDER}/swiftlint.txt" +OUTPUT_FILE_PATH="${PACKAGE_ROOT_FOLDER}/SwiftLintOutput" ${OUTPUT} > ${OUTPUT_FILE_PATH} #------------------------------------------------------------------------------- diff --git a/Tests/DarkModeTests/DarkModeTests/AppearanceServiceTests.swift b/Tests/DarkModeTests/DarkModeTests/AppearanceServiceTests.swift deleted file mode 100644 index 0daad71..0000000 --- a/Tests/DarkModeTests/DarkModeTests/AppearanceServiceTests.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// AppearanceServiceTests.swift -// DarkModeTests -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -#if canImport(UIKit) -import UIKit -#elseif canImport(Cocoa) -import Cocoa -#endif - -#if os(iOS) -public typealias View = UIView -public typealias ViewController = UIViewController -#elseif os(macOS) -public typealias View = NSView -public typealias ViewController = NSViewController -#endif - -import XCTest -@testable import PerseusDarkMode - -final class AppearanceServiceTests: XCTestCase { - - func test_init() { - XCTAssertFalse(AppearanceService.isEnabled) - XCTAssertFalse(AppearanceService.hidden_isEnabled) - - XCTAssertNotNil(AppearanceService.ud) - XCTAssertNotNil(AppearanceService.nCenter) - - let viewDarkMode = View().DarkMode as AnyObject - let videwControllerDarkMode = ViewController().DarkMode as AnyObject - let sharedDarkMode = AppearanceService.shared as AnyObject - -#if os(iOS) - XCTAssertEqual(ObjectIdentifier(viewDarkMode), ObjectIdentifier(sharedDarkMode)) - XCTAssertEqual(ObjectIdentifier(videwControllerDarkMode), - ObjectIdentifier(sharedDarkMode)) -#elseif os(macOS) - XCTAssertEqual(viewDarkMode.objectID, sharedDarkMode.objectID) - XCTAssertEqual(videwControllerDarkMode.objectID, sharedDarkMode.objectID) -#endif - } - - func test_method_register_called_addObserver() { - // arrange - - let mock = MockNotificationCenter() - AppearanceService.nCenter = mock - - // swiftlint:disable nesting - - class MyView: View { @objc func makeUp() { } } - - // swiftlint:enable nesting - - let view = MyView() - - // act - - AppearanceService.register(stakeholder: view, selector: #selector(view.makeUp)) - - // assert - - mock.verifyRegisterObserver(observer: view, selector: #selector(view.makeUp)) - } - - func test_method_makeAppearanceUp_called_post_and_isEnabled_true() { - // arrange - - let mock = MockNotificationCenter() - AppearanceService.nCenter = mock - - // act - - AppearanceService.makeUp() - - // assert - - mock.verifyPost(name: .MakeAppearanceUpNotification) - XCTAssertTrue(AppearanceService.isEnabled) - } - -#if os(macOS) - func test_Dark_Mode_called_addObserver_once() { - // arrange - - let mock = MockNotificationCenter() - AppearanceService.distributedNCenter = mock - - let view1 = View() - let view2 = View() - - // act - - // _ = AppearanceService.shared - // _ = AppearanceService.shared - - _ = view1.DarkMode - _ = view2.DarkMode - - // assert - - mock.verifyRegisterObserver(observer: AppearanceService.it, - selector: #selector(AppearanceService.modeChanged)) - } -#endif -} diff --git a/Tests/DarkModeTests/DarkModeTests/DarkModeDecisionTests.swift b/Tests/DarkModeTests/DarkModeTests/DarkModeDecisionTests.swift deleted file mode 100644 index 03a2ae4..0000000 --- a/Tests/DarkModeTests/DarkModeTests/DarkModeDecisionTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// DarkModeDecisionTests.swift -// DarkModeTests -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -#if canImport(UIKit) -import UIKit -#elseif canImport(Cocoa) -import Cocoa -#endif - -import XCTest -@testable import PerseusDarkMode - -final class DarkModeDecisionTests: XCTestCase { - - /// - /// Decision table for Actual Style: - /// - /// | DarkModeOption - /// | auto | on | off - /// -------------+--------------+---------+------+------ - /// System style | .unspecified | default | dark | light - /// System style | .light | light | dark | light - /// System style | .dark | dark | dark | light - /// - func test_calculateActualStyle_with_auto_and_unspecified_should_return_default() { - // arrange - - let userChoice = DarkModeOption.auto - let systemStyle = SystemStyle.unspecified - - // act - - let result = DarkModeDecision.calculate(userChoice, systemStyle) - - // assert - - XCTAssertEqual(result, DARK_MODE_STYLE_DEFAULT) - } - - func test_calculateActualStyle_with_on_and_unspecified_should_return_dark() { - // arrange - - let userChoice = DarkModeOption.on - let systemStyle = SystemStyle.unspecified - - // act - - let result = DarkModeDecision.calculate(userChoice, systemStyle) - - // assert - - XCTAssertEqual(result, .dark) - } - - func test_calculateActualStyle_with_off_and_unspecified_should_return_light() { - // arrange - - let userChoice = DarkModeOption.off - let systemStyle = SystemStyle.unspecified - - // act - - let result = DarkModeDecision.calculate(userChoice, systemStyle) - - // assert - - XCTAssertEqual(result, .light) - } - - func test_calculateActualStyle_with_auto_and_light_should_return_light() { - // arrange - - let userChoice = DarkModeOption.auto - let systemStyle = SystemStyle.light - - // act - - let result = DarkModeDecision.calculate(userChoice, systemStyle) - - // assert - - XCTAssertEqual(result, .light) - } - - func test_calculateActualStyle_with_on_and_light_should_return_dark() { - // arrange - - let userChoice = DarkModeOption.on - let systemStyle = SystemStyle.light - - // act - - let result = DarkModeDecision.calculate(userChoice, systemStyle) - - // assert - - XCTAssertEqual(result, .dark) - } - - func test_calculateActualStyle_with_off_and_light_should_return_light() { - // arrange - - let userChoice = DarkModeOption.off - let systemStyle = SystemStyle.light - - // act - - let result = DarkModeDecision.calculate(userChoice, systemStyle) - - // assert - - XCTAssertEqual(result, .light) - } - - func test_calculateActualStyle_with_auto_and_dark_should_return_dark() { - // arrange - - let userChoice = DarkModeOption.auto - let systemStyle = SystemStyle.dark - - // act - - let result = DarkModeDecision.calculate(userChoice, systemStyle) - - // assert - - XCTAssertEqual(result, .dark) - } - - func test_calculateActualStyle_with_on_and_dark_should_return_dark() { - // arrange - - let userChoice = DarkModeOption.on - let systemStyle = SystemStyle.dark - - // act - - let result = DarkModeDecision.calculate(userChoice, systemStyle) - - // assert - - XCTAssertEqual(result, .dark) - } - - func test_calculateActualStyle_with_off_and_dark_should_return_light() { - // arrange - - let userChoice = DarkModeOption.off - let systemStyle = SystemStyle.dark - - // act - - let result = DarkModeDecision.calculate(userChoice, systemStyle) - - // assert - - XCTAssertEqual(result, .light) - } -} diff --git a/Tests/DarkModeTests/DarkModeTests/DarkModeTests.swift b/Tests/DarkModeTests/DarkModeTests/DarkModeTests.swift deleted file mode 100644 index 0aba4b9..0000000 --- a/Tests/DarkModeTests/DarkModeTests/DarkModeTests.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// DarkModeTests.swift -// DarkModeTests -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -#if canImport(UIKit) -import UIKit -#elseif canImport(Cocoa) -import Cocoa -#endif - -import XCTest -@testable import PerseusDarkMode - -final class DarkModeTests: XCTestCase { - - func test_DarkMode_observable() { - // arrange - - var count: Int = 0 - var collection: [AppearanceStyle] = [] - - var observer: DarkModeObserver? = DarkModeObserver() - observer?.action = { newStyle in - - collection.append(newStyle) - count += 1 - } - - // act - - AppearanceService.shared.hidden_style = AppearanceStyle.dark - AppearanceService.shared.hidden_style = AppearanceStyle.light - - // assert - - XCTAssertEqual(count, 2) - XCTAssertEqual(collection, [AppearanceStyle.dark, AppearanceStyle.light]) - - // keep the room clean - - observer = nil - } - - func test_DarkMode_not_observable() { - // arrange - - var count: Int = 0 - var collection: [AppearanceStyle] = [] - - var observer: DarkModeObserver? = DarkModeObserver() - observer?.action = { newStyle in - - collection.append(newStyle) - count += 1 - } - - // act - - observer = nil - - AppearanceService.shared.hidden_style = AppearanceStyle.dark - AppearanceService.shared.hidden_style = AppearanceStyle.light - - // assert - - XCTAssertEqual(count, 0) - XCTAssertEqual(collection, []) - } - - func test_get_DarkModeUserChoice_when_valueExists_true() { - // arrange - - let mockUserDefaults = MockUserDefaults() - AppearanceService.ud = mockUserDefaults - - mockUserDefaults.isValueExists = true - - // act - - _ = AppearanceService.DarkModeUserChoice - - // assert - - mockUserDefaults.verifyInterger(name: DARK_MODE_USER_CHOICE_KEY) - - // keep it clean for the others - - AppearanceService.ud = UserDefaults.standard - } - - func test_get_DarkModeUserChoice_when_valueExists_false() { - // arrange - - let mockUserDefaults = MockUserDefaults() - AppearanceService.ud = mockUserDefaults - - mockUserDefaults.isValueExists = false - - // act - - let result = AppearanceService.DarkModeUserChoice - - // assert - - mockUserDefaults.verifyInterger() - - XCTAssertEqual(result, DARK_MODE_USER_CHOICE_DEFAULT) - - // keep it clean for the others - - AppearanceService.ud = UserDefaults.standard - } - - func test_set_DarkModeUserChoice() { - // arrange - - let mockUserDefaults = MockUserDefaults() - AppearanceService.ud = mockUserDefaults - - let sut = DarkModeOption.off - - // act - - AppearanceService.DarkModeUserChoice = sut - - // assert - - mockUserDefaults.verifySetValue(value: sut.rawValue, - key: DARK_MODE_USER_CHOICE_KEY) - - // keep it clean for the others - - AppearanceService.ud = UserDefaults.standard - } -} diff --git a/Tests/DarkModeTests/Helpers/MockNotificationCenter.swift b/Tests/DarkModeTests/Helpers/MockNotificationCenter.swift deleted file mode 100644 index b166538..0000000 --- a/Tests/DarkModeTests/Helpers/MockNotificationCenter.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// MockNotificationCenter.swift -// DarkModeTests -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -#if canImport(UIKit) -import UIKit -#elseif canImport(Cocoa) -import Cocoa -#endif - -#if os(iOS) -public typealias Responder = UIResponder -#elseif os(macOS) -public typealias Responder = NSResponder -#endif - -import XCTest -@testable import PerseusDarkMode - -class MockNotificationCenter: NotificationCenterProtocol { - - // MARK: - addObserver - - var registerCallCount = 0 - - var registerArgs_observers: [Any] = [] - var registerArgs_selectors: [Selector] = [] - - func addObserver(_ observer: Any, - selector aSelector: Selector, - name aName: NSNotification.Name?, - object anObject: Any?) { - // guard let observer = observer as? AnyObject else { return } - - registerCallCount += 1 - - registerArgs_observers.append(observer) - registerArgs_selectors.append(aSelector) - } - - func verifyRegisterObserver(observer: AnyObject, - selector: Selector, - file: StaticString = #file, - line: UInt = #line) { - guard registerWasCalledOnce(file: file, line: line) else { return } - - XCTAssertTrue(registerArgs_observers.first! as AnyObject === observer, - "observer", file: file, line: line) - - XCTAssertEqual(registerArgs_selectors.first, selector, - "selector", file: file, line: line) - } - - private func registerWasCalledOnce(file: StaticString = #file, - line: UInt = #line) -> Bool { - return - verifyMethodCalledOnce( - methodName: "register", - callCount: registerCallCount, - describeArguments: "name: \(registerArgs_selectors)", - file: file, - line: line) - } - - // MARK: - post - - var postCallCount = 0 - var postrgs_names: [String] = [] - - func post(name aName: NSNotification.Name, object anObject: Any?) { - postCallCount += 1 - postrgs_names.append(aName.rawValue) - } - - func verifyPost(name: NSNotification.Name, - file: StaticString = #file, - line: UInt = #line) { - guard postWasCalledOnce(file: file, line: line) else { return } - - XCTAssertTrue(postrgs_names.first! == name.rawValue, "name", file: file, line: line) - } - - private func postWasCalledOnce(file: StaticString = #file, line: UInt = #line) -> Bool { - return - verifyMethodCalledOnce( - methodName: "post", - callCount: postCallCount, - describeArguments: "name: \(postrgs_names)", - file: file, - line: line) - } -} - -private func verifyMethodCalledOnce(methodName: String, callCount: Int, - describeArguments: @autoclosure () -> String, - file: StaticString = #file, - line: UInt = #line) -> Bool { - if callCount == 0 { - XCTFail("Wanted but not invoked: \(methodName)", file: file, line: line) - return false - } - - if callCount > 1 { - XCTFail("Wanted 1 time but was called \(callCount) times. " + - "\(methodName) with \(describeArguments())", file: file, line: line) - return false - } - - return true -} diff --git a/Tests/DarkModeTests/Helpers/MockUserDefaults.swift b/Tests/DarkModeTests/Helpers/MockUserDefaults.swift deleted file mode 100644 index 73d4bba..0000000 --- a/Tests/DarkModeTests/Helpers/MockUserDefaults.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// MockUserDefaults.swift -// DarkModeTests -// -// Created by Mikhail Zhigulin in 7530. -// -// Copyright © 7530 - 7531 Mikhail Zhigulin of Novosibirsk. -// -// Licensed under the MIT license. See LICENSE file. -// All rights reserved. -// - -import XCTest -@testable import PerseusDarkMode - -class MockUserDefaults: UserDefaultsProtocol { - - var isValueExists = false - - func valueExists(forKey key: String) -> Bool { return isValueExists } - - // MARK: - getValue - - var intergerCallCount = 0 - var intergerArgs_names: [String] = [] - - func integer(forKey defaultName: String) -> Int { - intergerCallCount += 1 - intergerArgs_names.append(defaultName) - - return 0 - } - - func verifyInterger(name: String, - file: StaticString = #file, - line: UInt = #line) { - guard intergerWasCalledOnce(file: file, line: line) else { return } - - XCTAssertTrue(intergerArgs_names.first! == name, "interger", file: file, line: line) - } - - func verifyInterger(file: StaticString = #file, - line: UInt = #line) { - guard intergerWasNotCalled(file: file, line: line) else { return } - - XCTAssertTrue(intergerArgs_names.isEmpty, "interger", file: file, line: line) - } - - private func intergerWasCalledOnce(file: StaticString = #file, - line: UInt = #line) -> Bool { - return - verifyMethodCalledOnce( - methodName: "interger", - callCount: intergerCallCount, - describeArguments: "name: \(intergerArgs_names)", - file: file, - line: line) - } - - private func intergerWasNotCalled(file: StaticString = #file, line: UInt = #line) -> Bool { - return - verifyMethodNotCalled( - methodName: "interger", - callCount: intergerCallCount, - describeArguments: "name: \(intergerArgs_names)", - file: file, - line: line) - } - - // MARK: - setValue - - var setValueCallCount = 0 - - var setValueArgs_values: [Int] = [] - var setValueArgs_keys: [String] = [] - - func setValue(_ value: Any?, forKey key: String) { - guard let value = value as? Int else { return } - - setValueCallCount += 1 - - setValueArgs_values.append(value) - setValueArgs_keys.append(key) - } - - func verifySetValue(value: Int, - key: String, - file: StaticString = #file, - line: UInt = #line) { - guard setValueWasCalledOnce(file: file, line: line) else { return } - - XCTAssertTrue(setValueArgs_values.first! == value, - "value", file: file, line: line) - - XCTAssertEqual(setValueArgs_keys.first, key, - "key", file: file, line: line) - } - - private func setValueWasCalledOnce(file: StaticString = #file, - line: UInt = #line) -> Bool { - return - verifyMethodCalledOnce( - methodName: "setValue", - callCount: setValueCallCount, - describeArguments: "keys: \(setValueArgs_keys)", - file: file, - line: line) - } -} - -private func verifyMethodCalledOnce(methodName: String, callCount: Int, - describeArguments: @autoclosure () -> String, - file: StaticString = #file, - line: UInt = #line) -> Bool { - if callCount == 0 { - XCTFail("Wanted but not invoked: \(methodName)", file: file, line: line) - return false - } - - if callCount > 1 { - XCTFail("Wanted 1 time but was called \(callCount) times. " + - "\(methodName) with \(describeArguments())", file: file, line: line) - return false - } - - return true -} - -private func verifyMethodNotCalled(methodName: String, callCount: Int, - describeArguments: @autoclosure () -> String, - file: StaticString = #file, - line: UInt = #line) -> Bool { - if callCount != 0 { - XCTFail("Don't wanted but invoked: \(methodName)", file: file, line: line) - return false - } - - return true -} diff --git a/Tests/UnitTests/PDMStartTests.swift b/Tests/UnitTests/PDMStartTests.swift new file mode 100644 index 0000000..09aef9b --- /dev/null +++ b/Tests/UnitTests/PDMStartTests.swift @@ -0,0 +1,25 @@ +// +// PDMStartTests.swift +// UnitTests +// +// Created by Mikhail Zhigulin in 7530. +// +// Copyright © 7530 - 7533 Mikhail A. Zhigulin of Novosibirsk +// Copyright © 7533 PerseusRealDeal +// +// Licensed under the MIT license. See LICENSE file. +// All rights reserved. +// + +import XCTest +@testable import PerseusDarkMode + +final class FunctionalTests: XCTestCase { + + // func test_zero() { XCTFail("Tests not yet implemented in \(type(of: self)).") } + + func test_the_first_success() { + log.time = true + log.message(#function) + } +}