aboutsummaryrefslogtreecommitdiff
path: root/Hotline/Library
diff options
context:
space:
mode:
Diffstat (limited to 'Hotline/Library')
-rw-r--r--Hotline/Library/AsyncLinkPreview.swift57
-rw-r--r--Hotline/Library/DataAdditions.swift23
-rw-r--r--Hotline/Library/FileIconView.swift74
-rw-r--r--Hotline/Library/FileImageView.swift98
-rw-r--r--Hotline/Library/HotlinePanel.swift64
-rw-r--r--Hotline/Library/NetSocket.swift269
-rw-r--r--Hotline/Library/TextView.swift119
-rw-r--r--Hotline/Library/URLAdditions.swift26
-rw-r--r--Hotline/Library/VisualEffectView.swift22
9 files changed, 752 insertions, 0 deletions
diff --git a/Hotline/Library/AsyncLinkPreview.swift b/Hotline/Library/AsyncLinkPreview.swift
new file mode 100644
index 0000000..89b186f
--- /dev/null
+++ b/Hotline/Library/AsyncLinkPreview.swift
@@ -0,0 +1,57 @@
+import SwiftUI
+import LinkPresentation
+
+fileprivate class CustomLinkView: LPLinkView {
+ override var intrinsicContentSize: CGSize { CGSize(width: 0, height: super.intrinsicContentSize.height) }
+}
+
+struct AsyncLinkPreview: View {
+ @State private var metadata: LPLinkMetadata?
+ @State private var isLoading = true
+ let url: URL?
+
+ func fetchMetadata() async {
+ guard let url else {
+ self.isLoading = false
+ return
+ }
+ do {
+ let provider = LPMetadataProvider()
+ let metadata = try await provider.startFetchingMetadata(for: url)
+ self.metadata = metadata
+ self.isLoading = false
+ } catch {
+ self.isLoading = false
+ }
+ }
+
+ var body: some View {
+ if isLoading {
+ ProgressView()
+ .controlSize(.small)
+ .task {
+ await self.fetchMetadata()
+ }
+ } else if let metadata = metadata {
+ LinkView(metadata: metadata)
+ .frame(width: 200)
+ } else {
+ Text(LocalizedStringKey(url!.absoluteString))
+ .multilineTextAlignment(.leading)
+ .tint(Color("Link Color"))
+ }
+ }
+}
+
+struct LinkView: NSViewRepresentable {
+ var metadata: LPLinkMetadata
+
+ func makeNSView(context: Context) -> LPLinkView {
+ let linkView = CustomLinkView(metadata: metadata)
+ return linkView
+ }
+
+ func updateNSView(_ nsView: LPLinkView, context: Context) {
+ // Nothing required
+ }
+}
diff --git a/Hotline/Library/DataAdditions.swift b/Hotline/Library/DataAdditions.swift
new file mode 100644
index 0000000..0e9dd96
--- /dev/null
+++ b/Hotline/Library/DataAdditions.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+extension Data {
+ func saveAsFileToDownloads(filename: String, bounceDock: Bool = true) -> Bool {
+ let folderURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
+ let filePath = folderURL.generateUniqueFilePath(filename: filename)
+ if FileManager.default.createFile(atPath: filePath, contents: nil) {
+ if let h = FileHandle(forWritingAtPath: filePath) {
+ try? h.write(contentsOf: self)
+ try? h.close()
+ if bounceDock {
+ #if os(macOS)
+ var downloadURL = URL(filePath: filePath)
+ downloadURL.resolveSymlinksInPath()
+ DistributedNotificationCenter.default().post(name: .init("com.apple.DownloadFileFinished"), object: downloadURL.path)
+ #endif
+ }
+ return true
+ }
+ }
+ return false
+ }
+}
diff --git a/Hotline/Library/FileIconView.swift b/Hotline/Library/FileIconView.swift
new file mode 100644
index 0000000..d0949fa
--- /dev/null
+++ b/Hotline/Library/FileIconView.swift
@@ -0,0 +1,74 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct FolderIconView: View {
+ private func folderIcon() -> Image {
+#if os(iOS)
+ return Image(systemName: "folder.fill")
+#elseif os(macOS)
+ return Image(nsImage: NSWorkspace.shared.icon(for: UTType.folder))
+#endif
+ }
+
+ var body: some View {
+ folderIcon()
+ .resizable()
+ .scaledToFit()
+ }
+}
+
+struct FileIconView: View {
+ let filename: String
+ let fileType: String?
+
+ #if os(iOS)
+ private func fileIcon() -> Image {
+ let fileExtension = (self.filename as NSString).pathExtension
+ if let fileType = UTType(filenameExtension: fileExtension) {
+ if fileType.isSubtype(of: .movie) {
+ return Image(systemName: "play.rectangle")
+ }
+ else if fileType.isSubtype(of: .image) {
+ return Image(systemName: "photo")
+ }
+ else if fileType.isSubtype(of: .archive) {
+ return Image(systemName: "doc.zipper")
+ }
+ else if fileType.isSubtype(of: .text) {
+ return Image(systemName: "doc.text")
+ }
+ else {
+ return Image(systemName: "doc")
+ }
+ }
+
+ return Image(systemName: "doc")
+ }
+ #elseif os(macOS)
+ private func fileIcon() -> Image {
+ let fileExtension = (self.filename as NSString).pathExtension
+
+ if !fileExtension.isEmpty,
+ let uttype = UTType(filenameExtension: fileExtension) {
+ return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
+ }
+ else if let fileType = self.fileType,
+ let fileTypeExtension = FileManager.HFSTypeToExtension[fileType.lowercased()],
+ let uttype = UTType(filenameExtension: fileTypeExtension) {
+ return Image(nsImage: NSWorkspace.shared.icon(for: uttype))
+ }
+ else {
+ return Image(nsImage: NSWorkspace.shared.icon(for: UTType.data))
+ }
+
+// Image(nsImage: NSWorkspace.shared.icon(for: UTType(filenameExtension: (filename as NSString).pathExtension) ?? UTType.content))
+ }
+ #endif
+
+
+ var body: some View {
+ fileIcon()
+ .resizable()
+ .scaledToFit()
+ }
+}
diff --git a/Hotline/Library/FileImageView.swift b/Hotline/Library/FileImageView.swift
new file mode 100644
index 0000000..0cc50f1
--- /dev/null
+++ b/Hotline/Library/FileImageView.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+struct FileImageView: NSViewRepresentable {
+ var image: NSImage?
+
+ let minimumSize: CGSize = CGSize(width: 350, height: 350)
+ let presentationPaddingRatio: Double = 0.5
+
+ func makeNSView(context: Context) -> NSImageView {
+ let imageView = NSImageView()
+ imageView.imageScaling = .scaleProportionallyUpOrDown
+ imageView.animates = true
+ imageView.isEditable = false
+ imageView.allowsCutCopyPaste = true
+ imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+ imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+
+ if let img = self.image {
+ imageView.image = img
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.resizeWindowForImageView(imageView)
+ }
+ }
+
+ return imageView
+ }
+
+ func updateNSView(_ nsView: NSImageView, context: Context) {
+ nsView.image = self.image
+ }
+
+ // MARK: -
+
+ func resizeWindowForImageView(_ imageView: NSImageView) {
+ guard let window = imageView.window, let img = imageView.image else {
+ return
+ }
+
+ var windowRect = window.contentLayoutRect
+ let windowChromeSize = CGSize(width: window.frame.width - windowRect.width, height: window.frame.height - windowRect.height)
+
+ var windowMinSize: CGSize = windowRect.size
+ let centerPoint = CGPoint(x: window.frame.midX, y: window.frame.midY)
+
+ windowRect.size = img.size
+
+ if let screen = window.screen {
+ var paddedScreenSize = screen.frame.size
+ paddedScreenSize.width *= self.presentationPaddingRatio
+ paddedScreenSize.height *= self.presentationPaddingRatio
+
+ windowMinSize = aspectFit(source: windowRect.size, bounds: self.minimumSize)
+ windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: windowMinSize)
+ }
+
+ windowRect.size.width += windowChromeSize.width
+ windowRect.size.height += windowChromeSize.height
+
+ windowRect.origin.x = centerPoint.x - windowRect.width / 2.0
+ windowRect.origin.y = centerPoint.y - windowRect.height / 2.0
+
+ window.setFrame(windowRect, display: true, animate: true)
+
+// Do these APIs even work??
+// window.aspectRatio = windowRect.size
+// window.contentAspectRatio = windowRect.size
+ }
+
+ func aspectFit(source sourceSize: CGSize, bounds boundingSize: CGSize, minimum minSize: CGSize? = nil) -> CGSize {
+ let sourceAspectRatio = sourceSize.width / sourceSize.height
+
+ var fitSize: CGSize = sourceSize
+
+ if fitSize.width > boundingSize.width {
+ fitSize.width = boundingSize.width
+ fitSize.height = fitSize.width / sourceAspectRatio
+ }
+
+ if fitSize.height > boundingSize.height {
+ fitSize.height = boundingSize.height
+ fitSize.width = fitSize.height * sourceAspectRatio
+ }
+
+ if let m = minSize {
+ if fitSize.width < m.width {
+ fitSize.width = m.width
+ fitSize.height = fitSize.width / sourceAspectRatio
+ }
+
+ if fitSize.height < m.height {
+ fitSize.height = m.height
+ fitSize.width = fitSize.height * sourceAspectRatio
+ }
+ }
+
+ return fitSize
+ }
+}
diff --git a/Hotline/Library/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift
new file mode 100644
index 0000000..191e89a
--- /dev/null
+++ b/Hotline/Library/HotlinePanel.swift
@@ -0,0 +1,64 @@
+import Cocoa
+import SwiftUI
+
+fileprivate let HOTLINE_PANEL_SIZE: CGSize = CGSizeMake(468, 114 - 10)
+
+class HotlinePanel: NSPanel {
+ init(_ view: HotlinePanelView) {
+ super.init(contentRect: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height), styleMask: [.nonactivatingPanel, .titled, .closable, .utilityWindow, .fullSizeContentView], backing: .buffered, defer: false)
+
+ // Make sure that the panel is in front of almost all other windows
+ self.isFloatingPanel = false
+ self.level = .floating
+ self.hidesOnDeactivate = true
+ self.animationBehavior = .utilityWindow
+
+ // Allow the panel to appear in a fullscreen space
+// self.collectionBehavior.insert(.fullScreenAuxiliary)
+ self.collectionBehavior.insert(.canJoinAllSpaces)
+ self.collectionBehavior.insert(.ignoresCycle)
+
+// self.appearance = NSAppearance(named: .vibrantDark)
+
+ // Don't delete panel state when it's closed.
+ self.isReleasedWhenClosed = false
+
+ self.standardWindowButton(.closeButton)?.isHidden = true
+ self.standardWindowButton(.zoomButton)?.isHidden = true
+ self.standardWindowButton(.miniaturizeButton)?.isHidden = true
+
+ // Make it transparent, the view inside will have to set the background.
+ // This is necessary because otherwise, we will have some space for the titlebar on top of the height of the view itself which we don't want.
+ self.isOpaque = false
+ self.backgroundColor = .clear
+
+ // Since we don't show a statusbar, this allows us to drag the window by its background instead of the titlebar.
+ self.isMovableByWindowBackground = true
+ self.titlebarAppearsTransparent = true
+
+ let hostingView = NSHostingView(rootView: view.edgesIgnoringSafeArea(.top))
+ hostingView.sizingOptions = [.preferredContentSize]
+
+ let visualEffectView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: HOTLINE_PANEL_SIZE.width, height: HOTLINE_PANEL_SIZE.height))
+ visualEffectView.material = .sidebar
+ visualEffectView.blendingMode = .behindWindow
+ visualEffectView.state = NSVisualEffectView.State.active
+ visualEffectView.autoresizingMask = [.width, .height]
+ visualEffectView.autoresizesSubviews = true
+ visualEffectView.addSubview(hostingView)
+
+ self.contentView = visualEffectView
+
+ hostingView.frame = visualEffectView.bounds
+
+ self.cascadeTopLeft(from: NSMakePoint(16, 16))
+ }
+
+ override var canBecomeKey: Bool {
+ return false
+ }
+
+ override var canBecomeMain: Bool {
+ return false
+ }
+}
diff --git a/Hotline/Library/NetSocket.swift b/Hotline/Library/NetSocket.swift
new file mode 100644
index 0000000..a7d44ce
--- /dev/null
+++ b/Hotline/Library/NetSocket.swift
@@ -0,0 +1,269 @@
+
+// NetSocket.swift
+// A simple delegate based buffered read/write TCP socket.
+// Created by Dustin Mierau
+
+import Foundation
+
+protocol NetSocketDelegate: AnyObject {
+ func netsocketConnected(socket: NetSocket)
+ func netsocketDisconnected(socket: NetSocket, error: Error?)
+ func netsocketReceived(socket: NetSocket, bytes: [UInt8])
+ func netsocketSent(socket: NetSocket, count: Int)
+}
+
+extension NetSocketDelegate {
+ func netsocketConnected(socket: NetSocket) {}
+ func netsocketDisconnected(socket: NetSocket, error: Error?) {}
+ func netsocketReceived(socket: NetSocket, bytes: [UInt8]) {}
+ func netsocketSent(socket: NetSocket, count: Int) {}
+}
+
+enum NetSocketStatus {
+ case disconnected
+ case connecting
+ case connected
+}
+
+final class NetSocket: NSObject, StreamDelegate {
+ weak var delegate: NetSocketDelegate? = nil
+
+ private var output: OutputStream? = nil
+ private var input: InputStream? = nil
+
+ private var outputBuffer: [UInt8] = []
+ private var inputBuffer: [UInt8] = []
+
+ private var readBuffer: [UInt8] = Array(repeating: 0, count: 4 * 1024)
+
+ public func peek() -> [UInt8] { self.inputBuffer }
+ public var available: Int { self.inputBuffer.count }
+
+ private var status: NetSocketStatus = .disconnected
+
+ @MainActor public func has(_ length: Int) -> Bool {
+ return (self.available >= length)
+ }
+
+ override init() {}
+
+ @MainActor public func connect(host: String, port: Int) {
+ self.close()
+
+ var outputStream: OutputStream? = nil
+ var inputStream: InputStream? = nil
+
+ self.status = .connecting
+
+ Stream.getStreamsToHost(withName: host, port: port, inputStream: &inputStream, outputStream: &outputStream)
+
+ self.input = inputStream
+ self.output = outputStream
+
+ inputStream?.delegate = self
+ outputStream?.delegate = self
+
+ inputStream?.schedule(in: .current, forMode: .default)
+ outputStream?.schedule(in: .current, forMode: .default)
+
+ inputStream?.open()
+ outputStream?.open()
+ }
+
+ @MainActor public func close(_ err: Error? = nil) {
+ print("NetSocket: Closed")
+
+ let disconnected = (self.status != .disconnected)
+
+ self.status = .disconnected
+
+ self.input?.delegate = nil
+ self.output?.delegate = nil
+ self.input?.close()
+ self.output?.close()
+ self.input?.remove(from: .current, forMode: .default)
+ self.output?.remove(from: .current, forMode: .default)
+ self.input = nil
+ self.output = nil
+ self.inputBuffer = []
+ self.outputBuffer = []
+
+ if disconnected {
+ self.delegate?.netsocketDisconnected(socket: self, error: err)
+ }
+ }
+
+ @MainActor public func write(_ data: Data) {
+ guard let output = self.output else {
+ return
+ }
+
+ self.outputBuffer.append(contentsOf: data)
+
+ if output.hasSpaceAvailable {
+ self.writeBufferToStream()
+ }
+ }
+
+ @MainActor public func write(_ data: [UInt8]) {
+ guard let output = self.output else {
+ return
+ }
+
+ self.outputBuffer.append(contentsOf: data)
+
+ if output.hasSpaceAvailable {
+ self.writeBufferToStream()
+ }
+ }
+
+ @MainActor public func read(count: Int) -> [UInt8] {
+ guard self.inputBuffer.count > 0, count > 0 else {
+ return []
+ }
+
+ let amountToRead = min(count, self.inputBuffer.count)
+ let dataRead: [UInt8] = Array(self.inputBuffer[0..<amountToRead])
+ self.inputBuffer.removeFirst(amountToRead)
+
+ return dataRead
+ }
+
+ @MainActor public func read(count: Int) -> Data {
+ guard self.inputBuffer.count > 0, count > 0 else {
+ return Data()
+ }
+
+ let amountToRead = min(count, self.inputBuffer.count)
+
+ let dataRead: Data = Data(self.inputBuffer[0..<amountToRead])
+ self.inputBuffer.removeFirst(amountToRead)
+
+ return dataRead
+ }
+
+ @MainActor public func readAll() -> [UInt8] {
+ guard self.inputBuffer.count > 0 else {
+ return []
+ }
+
+ let dataRead: [UInt8] = Array(self.inputBuffer)
+ self.inputBuffer = []
+
+ return dataRead
+ }
+
+ @MainActor public func readAll() -> Data {
+ guard self.inputBuffer.count > 0 else {
+ return Data()
+ }
+
+ let dataRead: Data = Data(self.inputBuffer)
+ self.inputBuffer = []
+
+ return dataRead
+ }
+
+ @MainActor private func writeBufferToStream() {
+ guard let output = self.output, self.outputBuffer.count > 0 else {
+ return
+ }
+
+ let bytesWritten = output.write(self.outputBuffer, maxLength: self.outputBuffer.count)
+ print("NetSocket => \(bytesWritten) bytes")
+ if bytesWritten > 0 {
+ self.outputBuffer.removeFirst(bytesWritten)
+ self.delegate?.netsocketSent(socket: self, count: bytesWritten)
+ }
+ else if bytesWritten == -1 {
+ self.close(output.streamError)
+ }
+ }
+
+ @MainActor private func readStreamToBuffer() {
+ guard let input = self.input else {
+ return
+ }
+
+ let bytesRead = input.read(&self.readBuffer, maxLength: 4 * 1024)
+ print("NetSocket <= \(bytesRead) bytes")
+ if bytesRead > 0 {
+ self.inputBuffer.append(contentsOf: self.readBuffer[0..<bytesRead])
+ self.delegate?.netsocketReceived(socket: self, bytes: self.inputBuffer)
+ }
+ else if bytesRead == -1 {
+ self.close(input.streamError)
+ }
+ }
+
+ // MARK: -
+
+ @MainActor func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
+ guard let input = self.input, let output = self.output else {
+ return
+ }
+
+ switch eventCode {
+ case .openCompleted:
+ if aStream == input {
+ self.setupStreamOptions()
+ }
+ if input.streamStatus == .open && output.streamStatus == .open {
+ if self.status == .connecting {
+ print("NetSocket: Connected")
+ self.status = .connected
+ self.delegate?.netsocketConnected(socket: self)
+ }
+ }
+ break
+ case .hasBytesAvailable:
+ print("NetSocket: Data available")
+ self.readStreamToBuffer()
+ case .hasSpaceAvailable:
+ print("NetSocket: Space available")
+ self.writeBufferToStream()
+ case .endEncountered:
+ print("NetSocket: End encounterd")
+ self.close()
+ case .errorOccurred:
+ print("NetSocket: Error encounterd", input.streamError.debugDescription, output.streamError.debugDescription)
+ let err = input.streamError ?? output.streamError
+ self.close(err)
+ default:
+ break
+ }
+ }
+
+ // MARK: -
+
+ private func setupStreamOptions() {
+ if let input = self.input {
+ let socketData: Data = CFReadStreamCopyProperty(input as CFReadStream, CFStreamPropertyKey.socketNativeHandle) as! Data;
+ var socketHandle: CFSocketNativeHandle = 0;
+ (socketData as NSData).getBytes(&socketHandle, length: MemoryLayout.size(ofValue: socketHandle));
+
+ var value: Int = 0;
+ let size = UInt32(MemoryLayout.size(ofValue: value));
+
+ value = 1;
+ if setsockopt(socketHandle, IPPROTO_TCP, TCP_NODELAY, &value, size) != 0 {
+ print("NetSocket: failed to set TCP_NODELAY");
+ }
+ // Enable keepalive
+ value = 1;
+ if setsockopt(socketHandle, SOL_SOCKET, SO_KEEPALIVE, &value, size) != 0 {
+ print("NetSocket: failed to set SO_KEEPALIVE");
+ }
+// // Number of keepalives before close (including first keepalive packet)
+// value = 5
+// if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPCNT, &value, size) != 0 {
+// print("NetSocket: failed to set TCP_KEEPCNT");
+// }
+// // Idle time used when SO_KEEPALIVE is enabled. Sets how long connection must be idle before keepalive is sent.
+// value = 60
+// if setsockopt(socketHandle, IPPROTO_TCP, TCP_KEEPALIVE, &value, size) != 0 {
+// print("NetSocket: failed to set TCP_KEEPALIVE")
+// }
+ }
+ }
+}
diff --git a/Hotline/Library/TextView.swift b/Hotline/Library/TextView.swift
new file mode 100644
index 0000000..47746e2
--- /dev/null
+++ b/Hotline/Library/TextView.swift
@@ -0,0 +1,119 @@
+import SwiftUI
+
+struct BetterTextEditor: NSViewRepresentable {
+
+ @Environment(\.lineSpacing) private var lineSpacing
+
+ @Binding private var text: String
+
+ private var customizations: [(NSTextView) -> Void] = []
+
+ init(text: Binding<String>) {
+ self._text = text
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ func makeNSView(context: Context) -> NSScrollView {
+ let scrollview = NSTextView.scrollablePlainDocumentContentTextView()
+ let textview = scrollview.documentView as! NSTextView
+
+ textview.string = self.text
+ textview.delegate = context.coordinator
+
+// let p = NSMutableParagraphStyle()
+// p.lineSpacing = self.lineSpacing
+//// textview.defaultParagraphStyle = p
+// textview.typingAttributes = [
+// .paragraphStyle: p
+// ]
+
+ textview.isEditable = true
+ textview.isRichText = false
+ textview.allowsUndo = true
+ textview.isFieldEditor = false
+ textview.usesAdaptiveColorMappingForDarkAppearance = true
+ textview.drawsBackground = false // true
+ textview.usesRuler = false
+ textview.usesFindBar = false
+ textview.isIncrementalSearchingEnabled = false
+ textview.isAutomaticQuoteSubstitutionEnabled = false
+ textview.isAutomaticDashSubstitutionEnabled = false
+ textview.isAutomaticSpellingCorrectionEnabled = true
+ textview.isAutomaticDataDetectionEnabled = false
+ textview.isAutomaticLinkDetectionEnabled = false
+ textview.usesInspectorBar = false
+ textview.usesFontPanel = false
+ textview.importsGraphics = false
+ textview.allowsImageEditing = false
+ textview.displaysLinkToolTips = true
+ textview.backgroundColor = NSColor.textBackgroundColor
+ textview.textContainerInset = NSSize(width: 16, height: 16)
+ textview.isContinuousSpellCheckingEnabled = true
+ textview.setSelectedRange(NSMakeRange(0, 0))
+ self.customizations.forEach { $0(textview) }
+
+ scrollview.scrollerStyle = .overlay
+
+ return scrollview
+ }
+
+ func updateNSView(_ nsView: NSScrollView, context: Context) {
+ let textview = nsView.documentView as! NSTextView
+
+ if textview.string != text {
+ textview.string = text
+ }
+
+ self.customizations.forEach { $0(textview) }
+ }
+
+ func betterEditorFont(_ font: NSFont) -> Self {
+ self.customized { $0.font = font }
+ }
+
+ func betterEditorParagraphStyle(_ paragraphStyle: NSParagraphStyle) -> Self {
+ self.customized { $0.defaultParagraphStyle = paragraphStyle }
+ }
+
+ func betterEditorAutomaticDashSubstitution(_ enabled: Bool) -> Self {
+ self.customized { $0.isAutomaticDashSubstitutionEnabled = enabled }
+ }
+
+ func betterEditorAutomaticQuoteSubstitution(_ enabled: Bool) -> Self {
+ self.customized { $0.isAutomaticQuoteSubstitutionEnabled = enabled }
+ }
+
+ func betterEditorAutomaticSpellingCorrection(_ enabled: Bool) -> Self {
+ self.customized { $0.isAutomaticSpellingCorrectionEnabled = enabled }
+ }
+
+ func betterEditorTextInset(_ size: NSSize) -> Self {
+ self.customized { $0.textContainerInset = size }
+ }
+
+ class Coordinator: NSObject, NSTextViewDelegate {
+ var parent: BetterTextEditor
+
+ init(_ parent: BetterTextEditor) {
+ self.parent = parent
+ }
+
+ func textDidChange(_ notification: Notification) {
+ guard let textview = notification.object as? NSTextView else {
+ return
+ }
+ self.parent.text = textview.string
+ }
+ }
+}
+
+private extension BetterTextEditor {
+ private func customized(_ customization: @escaping (NSTextView) -> Void) -> Self {
+ var copy = self
+ copy.customizations.append(customization)
+ return copy
+ }
+}
diff --git a/Hotline/Library/URLAdditions.swift b/Hotline/Library/URLAdditions.swift
new file mode 100644
index 0000000..1f25541
--- /dev/null
+++ b/Hotline/Library/URLAdditions.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+extension URL {
+ func generateUniqueFilePath(filename base: String) -> String {
+ let fileManager = FileManager.default
+ var finalName = base
+ var counter = 2
+
+ // Helper function to generate a new filename with a counter
+ func makeFileName() -> String {
+ let baseName = (base as NSString).deletingPathExtension
+ let extensionName = (base as NSString).pathExtension
+ return extensionName.isEmpty ? "\(baseName) \(counter)" : "\(baseName) \(counter).\(extensionName)"
+ }
+
+ // Check if file exists and append counter until a unique name is found
+ var filePath = self.appending(component: finalName).path(percentEncoded: false)
+ while fileManager.fileExists(atPath: filePath) {
+ finalName = makeFileName()
+ filePath = self.appending(component: finalName).path(percentEncoded: false)
+ counter += 1
+ }
+
+ return filePath
+ }
+}
diff --git a/Hotline/Library/VisualEffectView.swift b/Hotline/Library/VisualEffectView.swift
new file mode 100644
index 0000000..e595c55
--- /dev/null
+++ b/Hotline/Library/VisualEffectView.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+
+struct VisualEffectView: NSViewRepresentable
+{
+ let material: NSVisualEffectView.Material
+ let blendingMode: NSVisualEffectView.BlendingMode
+
+ func makeNSView(context: Context) -> NSVisualEffectView
+ {
+ let visualEffectView = NSVisualEffectView()
+ visualEffectView.material = material
+ visualEffectView.blendingMode = blendingMode
+ visualEffectView.state = NSVisualEffectView.State.active
+ return visualEffectView
+ }
+
+ func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context)
+ {
+ visualEffectView.material = material
+ visualEffectView.blendingMode = blendingMode
+ }
+}