Initial commit

This commit is contained in:
2024-06-02 11:48:21 +02:00
commit 476feaa879
11 changed files with 466 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

21
LICENSE Normal file
View File

@ -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.

20
Package.swift Normal file
View File

@ -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"]),
]
)

23
README.md Normal file
View File

@ -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()
```

View File

@ -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
)
)
}
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,5 @@
import Foundation
protocol NVAlertableError {
func getErrorMessageKey() -> String
}

View File

@ -0,0 +1,180 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22690"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--AlertVC-->
<scene sceneID="k1w-dS-AUA">
<objects>
<viewController storyboardIdentifier="noticeVC" id="Fib-nQ-xWn" customClass="NVAlertVC" customModule="NVAlert" sceneMemberID="viewController">
<view key="view" id="Dol-uH-OmZ">
<rect key="frame" x="0.0" y="0.0" width="500" height="212"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<visualEffectView blendingMode="behindWindow" material="popover" state="followsWindowActiveState" translatesAutoresizingMaskIntoConstraints="NO" id="TK5-ft-RTC">
<rect key="frame" x="0.0" y="0.0" width="500" height="212"/>
<subviews>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="glZ-s9-p3a">
<rect key="frame" x="383" y="13" width="104" height="32"/>
<buttonCell key="cell" type="push" title="Primary" bezelStyle="rounded" alignment="center" borderStyle="border" inset="2" id="JBv-EB-QYu">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
DQ
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="90" id="fqc-j1-0xt"/>
</constraints>
<connections>
<action selector="primaryButtonAction:" target="Fib-nQ-xWn" id="7Lo-3h-5v0"/>
</connections>
</button>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="eMy-LR-df3">
<rect key="frame" x="281" y="13" width="104" height="32"/>
<buttonCell key="cell" type="push" title="Secondary" bezelStyle="rounded" alignment="center" borderStyle="border" inset="2" id="JpX-k6-Rvu">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="90" id="WSV-2Y-d6r"/>
</constraints>
<connections>
<action selector="secondaryButtonAction:" target="Fib-nQ-xWn" id="Eur-fz-Z7O"/>
</connections>
</button>
<button wantsLayer="YES" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Cih-OK-f1X">
<rect key="frame" x="13" y="13" width="81" height="32"/>
<buttonCell key="cell" type="push" title="Tertiary" bezelStyle="rounded" alignment="center" borderStyle="border" inset="2" id="zKJ-wZ-d63">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="tertiaryButtonAction:" target="Fib-nQ-xWn" id="1Kb-aK-Aua"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstAttribute="bottom" secondItem="glZ-s9-p3a" secondAttribute="bottom" constant="20" symbolic="YES" id="5rO-Qb-XXE"/>
<constraint firstItem="Cih-OK-f1X" firstAttribute="leading" secondItem="TK5-ft-RTC" secondAttribute="leading" constant="20" symbolic="YES" id="A7n-Sm-1ha"/>
<constraint firstItem="Cih-OK-f1X" firstAttribute="bottom" secondItem="eMy-LR-df3" secondAttribute="bottom" id="GBm-uq-pA7"/>
<constraint firstAttribute="bottom" secondItem="eMy-LR-df3" secondAttribute="bottom" constant="20" symbolic="YES" id="GWz-5e-H2n"/>
<constraint firstItem="glZ-s9-p3a" firstAttribute="leading" secondItem="eMy-LR-df3" secondAttribute="trailing" constant="12" symbolic="YES" id="Jqh-C4-dw4"/>
<constraint firstAttribute="bottom" secondItem="Cih-OK-f1X" secondAttribute="bottom" constant="20" symbolic="YES" id="RDP-uA-ti3"/>
<constraint firstAttribute="trailing" secondItem="glZ-s9-p3a" secondAttribute="trailing" constant="20" symbolic="YES" id="VBR-Q0-NzW"/>
<constraint firstAttribute="trailing" secondItem="glZ-s9-p3a" secondAttribute="trailing" constant="20" symbolic="YES" id="vR3-e8-l3i"/>
<constraint firstItem="eMy-LR-df3" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="Cih-OK-f1X" secondAttribute="trailing" constant="12" symbolic="YES" id="vb7-zi-S48"/>
<constraint firstAttribute="bottom" secondItem="glZ-s9-p3a" secondAttribute="bottom" constant="20" symbolic="YES" id="y7Q-UK-jiB"/>
</constraints>
</visualEffectView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="W3Q-oe-uSr">
<rect key="frame" x="98" y="153" width="384" height="19"/>
<constraints>
<constraint firstAttribute="width" relation="lessThanOrEqual" constant="380" id="Qas-PU-3VH"/>
</constraints>
<textFieldCell key="cell" selectable="YES" title="This is the title of the notice window." id="lHK-Bt-Li3">
<font key="font" metaFont="systemBold" size="15"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="tgD-Mg-YN4">
<rect key="frame" x="98" y="127" width="384" height="16"/>
<textFieldCell key="cell" selectable="YES" title="This is a slightly more expanded explanation." id="m3c-P9-gfK">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<button fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="3DL-Vb-hLF">
<rect key="frame" x="12" y="144" width="48" height="48"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<buttonCell key="cell" type="square" bezelStyle="shadowlessSquare" imagePosition="only" alignment="center" imageScaling="proportionallyUpOrDown" inset="2" id="me9-6W-T16">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
</button>
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="GQ7-OB-bDn">
<rect key="frame" x="20" y="111" width="64" height="64"/>
<constraints>
<constraint firstAttribute="height" constant="64" id="3LN-dA-F7M"/>
<constraint firstAttribute="width" constant="64" id="Ug6-65-gJ4"/>
</constraints>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" id="ocb-ea-QMS"/>
</imageView>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="0ra-hO-Hjb">
<rect key="frame" x="98" y="70" width="384" height="42"/>
<textFieldCell key="cell" selectable="YES" id="dlD-Jr-DXC">
<font key="font" metaFont="smallSystem"/>
<string key="title">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!</string>
<color key="textColor" name="secondaryLabelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<constraints>
<constraint firstItem="W3Q-oe-uSr" firstAttribute="leading" secondItem="GQ7-OB-bDn" secondAttribute="trailing" constant="16" id="5jE-dU-EcP"/>
<constraint firstItem="GQ7-OB-bDn" firstAttribute="top" secondItem="W3Q-oe-uSr" secondAttribute="top" constant="-3" id="9az-OF-bd6"/>
<constraint firstItem="glZ-s9-p3a" firstAttribute="top" secondItem="0ra-hO-Hjb" secondAttribute="bottom" constant="30" id="Gho-MH-E5R"/>
<constraint firstItem="GQ7-OB-bDn" firstAttribute="leading" secondItem="Dol-uH-OmZ" secondAttribute="leading" constant="20" symbolic="YES" id="Jy3-2p-Xfi"/>
<constraint firstItem="0ra-hO-Hjb" firstAttribute="leading" secondItem="tgD-Mg-YN4" secondAttribute="leading" id="Q91-z5-Aru"/>
<constraint firstAttribute="trailing" secondItem="TK5-ft-RTC" secondAttribute="trailing" id="R29-Ez-4Vr"/>
<constraint firstItem="tgD-Mg-YN4" firstAttribute="trailing" secondItem="W3Q-oe-uSr" secondAttribute="trailing" id="Rvt-wQ-Qlu"/>
<constraint firstAttribute="trailing" secondItem="W3Q-oe-uSr" secondAttribute="trailing" constant="20" symbolic="YES" id="Xke-N3-MQO"/>
<constraint firstItem="W3Q-oe-uSr" firstAttribute="top" secondItem="Dol-uH-OmZ" secondAttribute="top" constant="40" id="Yxh-mG-9V4"/>
<constraint firstItem="tgD-Mg-YN4" firstAttribute="leading" secondItem="W3Q-oe-uSr" secondAttribute="leading" id="cok-Hc-1yU"/>
<constraint firstItem="TK5-ft-RTC" firstAttribute="top" secondItem="Dol-uH-OmZ" secondAttribute="top" id="dLM-3k-K3s"/>
<constraint firstItem="0ra-hO-Hjb" firstAttribute="top" secondItem="tgD-Mg-YN4" secondAttribute="bottom" constant="15" id="dds-45-S0W"/>
<constraint firstItem="0ra-hO-Hjb" firstAttribute="trailing" secondItem="tgD-Mg-YN4" secondAttribute="trailing" id="qSo-G9-3vT"/>
<constraint firstAttribute="bottom" secondItem="TK5-ft-RTC" secondAttribute="bottom" id="rcu-CP-dZk"/>
<constraint firstItem="TK5-ft-RTC" firstAttribute="leading" secondItem="Dol-uH-OmZ" secondAttribute="leading" id="sG1-PM-7OO"/>
<constraint firstItem="tgD-Mg-YN4" firstAttribute="top" secondItem="W3Q-oe-uSr" secondAttribute="bottom" constant="10" id="xg1-yQ-wBh"/>
</constraints>
</view>
<connections>
<outlet property="buttonPrimary" destination="glZ-s9-p3a" id="Jbe-0I-Vrm"/>
<outlet property="buttonSecondary" destination="eMy-LR-df3" id="iTa-Yk-UTu"/>
<outlet property="buttonTertiary" destination="Cih-OK-f1X" id="Q51-nr-umm"/>
<outlet property="imageView" destination="GQ7-OB-bDn" id="6c1-kL-u33"/>
<outlet property="labelDescription" destination="0ra-hO-Hjb" id="WFn-lY-hmQ"/>
<outlet property="labelSubtitle" destination="tgD-Mg-YN4" id="fxm-4M-3cu"/>
<outlet property="labelTitle" destination="W3Q-oe-uSr" id="QYO-gb-aQO"/>
<outlet property="primaryButtonTopMargin" destination="Gho-MH-E5R" id="saQ-nQ-322"/>
</connections>
</viewController>
<customObject id="KbB-Mj-PlN" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="230" y="2267"/>
</scene>
<!--Window Controller-->
<scene sceneID="Xsy-Ws-OsP">
<objects>
<windowController storyboardIdentifier="window" id="yFW-Ep-rS8" sceneMemberID="viewController">
<window key="window" title="Notice" separatorStyle="none" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="alertPanel" frameAutosaveName="" titlebarAppearsTransparent="YES" toolbarStyle="unified" titleVisibility="hidden" id="1ff-Fi-MzV">
<windowStyleMask key="styleMask" titled="YES" fullSizeContentView="YES"/>
<rect key="contentRect" x="425" y="462" width="480" height="270"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1415"/>
<view key="contentView" id="i7I-3l-Avl">
<rect key="frame" x="0.0" y="0.0" width="480" height="270"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<connections>
<outlet property="delegate" destination="yFW-Ep-rS8" id="zbW-GZ-m3U"/>
</connections>
</window>
<connections>
<segue destination="Fib-nQ-xWn" kind="relationship" relationship="window.shadowedContentViewController" id="Iax-Bg-bFz"/>
</connections>
</windowController>
<customObject id="dw0-2X-8Hj" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-374" y="2267"/>
</scene>
</scenes>
</document>

View File

@ -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
}
}