From 476feaa87964f7f04ead85c1c0b4623068b23b3c Mon Sep 17 00:00:00 2001 From: Nico Verbruggen Date: Sun, 2 Jun 2024 11:48:21 +0200 Subject: [PATCH] Initial commit --- .gitignore | 8 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + LICENSE | 21 ++ Package.swift | 20 ++ README.md | 23 +++ .../NVAlert/Extensions/NSWindow+Center.swift | 16 ++ Sources/NVAlert/NVAlert.swift | 103 ++++++++++ Sources/NVAlert/NVAlertVC.swift | 70 +++++++ Sources/NVAlert/NVAlertableError.swift | 5 + Sources/NVAlert/Resources/NVAlert.storyboard | 180 ++++++++++++++++++ Tests/NVAlertTests/NVAlertTests.swift | 12 ++ 11 files changed, 466 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/NVAlert/Extensions/NSWindow+Center.swift create mode 100644 Sources/NVAlert/NVAlert.swift create mode 100644 Sources/NVAlert/NVAlertVC.swift create mode 100644 Sources/NVAlert/NVAlertableError.swift create mode 100644 Sources/NVAlert/Resources/NVAlert.storyboard create mode 100644 Tests/NVAlertTests/NVAlertTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b3e453 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Nico Verbruggen + +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. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..9ec5c7e --- /dev/null +++ b/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "NVAlert", + products: [ + .library( + name: "NVAlert", + targets: ["NVAlert"]), + ], + targets: [ + .target( + name: "NVAlert"), + .testTarget( + name: "NVAlertTests", + dependencies: ["NVAlert"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b70ed8 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# NVAlert Package + +**Important**: 👷‍♂️ This package is currently **under construction**, and may change at any time. + +## What is this? + +This is a package that helps you present larger alerts (with more text) for macOS, if you dislike the smaller alerts introduced in more recent versions of macOS. + +Since PHP Monitor displays many helpful prompts, I wanted something a little bit more robust than the default alerts which ship with macOS. + +This was originally written as part of my "zero non first-party dependencies" policy for [PHP Monitor](https://github.com/nicoverbruggen/phpmon). + +## Example usage + +```swift +NVAlert().withInformation( + title: NSLocalizedString("lite_mode_explanation.title", nil), + subtitle: NSLocalizedString("lite_mode_explanation.subtitle", nil), + description: NSLocalizedString("lite_mode_explanation.description", nil) +) +.withPrimary(text: NSLocalizedString("generic.ok", nil)) +.show() +``` diff --git a/Sources/NVAlert/Extensions/NSWindow+Center.swift b/Sources/NVAlert/Extensions/NSWindow+Center.swift new file mode 100644 index 0000000..b022e9d --- /dev/null +++ b/Sources/NVAlert/Extensions/NSWindow+Center.swift @@ -0,0 +1,16 @@ +import Foundation +import Cocoa + +extension NSWindow { + /** Centers a window. Taken from: https://stackoverflow.com/a/66140320 */ + public func setCenterPosition(offsetY: CGFloat = 0) { + if let screenSize = screen?.visibleFrame.size { + self.setFrameOrigin( + NSPoint( + x: (screenSize.width - frame.size.width) / 2, + y: (screenSize.height - frame.size.height) / 2 + offsetY + ) + ) + } + } +} diff --git a/Sources/NVAlert/NVAlert.swift b/Sources/NVAlert/NVAlert.swift new file mode 100644 index 0000000..fad11ef --- /dev/null +++ b/Sources/NVAlert/NVAlert.swift @@ -0,0 +1,103 @@ +import Foundation +import Cocoa + +@MainActor +open class NVAlert { + + var windowController: NSWindowController! + + var noticeVC: NVAlertVC { + return self.windowController.contentViewController as! NVAlertVC + } + + public init() { + let storyboard = NSStoryboard(name: "NVAlert", bundle: Bundle.module) + + self.windowController = storyboard.instantiateController( + withIdentifier: "window" + ) as? NSWindowController + } + + public static func make() -> NVAlert { + return NVAlert() + } + + public func withPrimary( + text: String, + action: @MainActor @escaping (NVAlertVC) -> Void = { vc in + vc.close(with: .alertFirstButtonReturn) + } + ) -> Self { + self.noticeVC.buttonPrimary.title = text + self.noticeVC.actionPrimary = action + return self + } + + public func withSecondary( + text: String, + action: (@MainActor (NVAlertVC) -> Void)? = { vc in + vc.close(with: .alertSecondButtonReturn) + } + ) -> Self { + self.noticeVC.buttonSecondary.title = text + self.noticeVC.actionSecondary = action + return self + } + + public func withTertiary( + text: String = "", + action: (@MainActor (NVAlertVC) -> Void)? = nil + ) -> Self { + if text == "" { + self.noticeVC.buttonTertiary.bezelStyle = .helpButton + } + self.noticeVC.buttonTertiary.title = text + self.noticeVC.actionTertiary = action + return self + } + + public func withInformation( + title: String, + subtitle: String, + description: String = "" + ) -> Self { + self.noticeVC.labelTitle.stringValue = title + self.noticeVC.labelSubtitle.stringValue = subtitle + self.noticeVC.labelDescription.stringValue = description + + // If the description is missing, handle the excess space and change the top margin + if description == "" { + self.noticeVC.labelDescription.isHidden = true + self.noticeVC.primaryButtonTopMargin.constant = 0 + } + return self + } + + /** + Shows the modal and returns a ModalResponse. + If you wish to simply show the alert and disregard the outcome, use `show`. + */ + @MainActor public func runModal() -> NSApplication.ModalResponse { + if !Thread.isMainThread { + fatalError("You should always present alerts on the main thread!") + } + + NSApp.activate(ignoringOtherApps: true) + + windowController.window?.makeKeyAndOrderFront(nil) + windowController.window?.setCenterPosition(offsetY: 70) + return NSApplication.shared.runModal(for: windowController.window!) + } + + /** Shows the modal and returns true if the user pressed the primary button. */ + @MainActor public func didSelectPrimary() -> Bool { + return self.runModal() == .alertFirstButtonReturn + } + + /** + Shows the modal and does not return anything. + */ + @MainActor public func show() { + _ = self.runModal() + } +} diff --git a/Sources/NVAlert/NVAlertVC.swift b/Sources/NVAlert/NVAlertVC.swift new file mode 100644 index 0000000..b856e8b --- /dev/null +++ b/Sources/NVAlert/NVAlertVC.swift @@ -0,0 +1,70 @@ +import Foundation +import Cocoa + +open class NVAlertVC: NSViewController { + + // MARK: - Outlets + + @IBOutlet weak var labelTitle: NSTextField! + @IBOutlet weak var labelSubtitle: NSTextField! + @IBOutlet weak var labelDescription: NSTextField! + + @IBOutlet weak var buttonPrimary: NSButton! + @IBOutlet weak var buttonSecondary: NSButton! + @IBOutlet weak var buttonTertiary: NSButton! + + var actionPrimary: (@MainActor (NVAlertVC) -> Void) = { _ in } + var actionSecondary: (@MainActor (NVAlertVC) -> Void)? + var actionTertiary: (@MainActor (NVAlertVC) -> Void)? + + @IBOutlet weak var imageView: NSImageView! + + @IBOutlet weak var primaryButtonTopMargin: NSLayoutConstraint! + + // MARK: - Lifecycle + + open override func viewWillAppear() { + imageView.image = NSApp.applicationIconImage + + if actionSecondary == nil { + buttonSecondary.isHidden = true + } + if actionTertiary == nil { + buttonTertiary.isHidden = true + } + } + + open override func viewDidAppear() { + view.window?.makeFirstResponder(buttonPrimary) + } + + deinit { + // print("deinit: \(String(describing: self)).\(#function)") + } + + // MARK: Outlet Actions + + @IBAction func primaryButtonAction(_ sender: Any) { + self.actionPrimary(self) + } + + @IBAction func secondaryButtonAction(_ sender: Any) { + if self.actionSecondary != nil { + self.actionSecondary!(self) + } else { + self.close(with: .alertSecondButtonReturn) + } + } + + @IBAction func tertiaryButtonAction(_ sender: Any) { + if self.actionTertiary != nil { + self.actionTertiary!(self) + } + } + + @MainActor public func close(with code: NSApplication.ModalResponse) { + self.view.window?.close() + NSApplication.shared.stopModal(withCode: code) + } +} + diff --git a/Sources/NVAlert/NVAlertableError.swift b/Sources/NVAlert/NVAlertableError.swift new file mode 100644 index 0000000..52af49a --- /dev/null +++ b/Sources/NVAlert/NVAlertableError.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol NVAlertableError { + func getErrorMessageKey() -> String +} diff --git a/Sources/NVAlert/Resources/NVAlert.storyboard b/Sources/NVAlert/Resources/NVAlert.storyboard new file mode 100644 index 0000000..53e118a --- /dev/null +++ b/Sources/NVAlert/Resources/NVAlert.storyboard @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Sometimes you need a really long explanation and in that case you can get a really, really long description here, along with, for example, various steps you can take. This allows for a lot of text to be displayed, yay! + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/NVAlertTests/NVAlertTests.swift b/Tests/NVAlertTests/NVAlertTests.swift new file mode 100644 index 0000000..792011f --- /dev/null +++ b/Tests/NVAlertTests/NVAlertTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import NVAlert + +final class NVAlertTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +}