diff options
Diffstat (limited to 'Hotline/Library/Views')
| -rw-r--r-- | Hotline/Library/Views/AsyncLinkPreview.swift | 57 | ||||
| -rw-r--r-- | Hotline/Library/Views/BetterTextEditor.swift | 119 | ||||
| -rw-r--r-- | Hotline/Library/Views/FileIconView.swift | 74 | ||||
| -rw-r--r-- | Hotline/Library/Views/FileImageView.swift | 98 | ||||
| -rw-r--r-- | Hotline/Library/Views/GroupedIconView.swift | 23 | ||||
| -rw-r--r-- | Hotline/Library/Views/HotlinePanel.swift | 64 | ||||
| -rw-r--r-- | Hotline/Library/Views/QuickLookPreviewView.swift | 22 | ||||
| -rw-r--r-- | Hotline/Library/Views/SpinningGlobeView.swift | 90 | ||||
| -rw-r--r-- | Hotline/Library/Views/VisualEffectView.swift | 22 |
9 files changed, 569 insertions, 0 deletions
diff --git a/Hotline/Library/Views/AsyncLinkPreview.swift b/Hotline/Library/Views/AsyncLinkPreview.swift new file mode 100644 index 0000000..89b186f --- /dev/null +++ b/Hotline/Library/Views/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/Views/BetterTextEditor.swift b/Hotline/Library/Views/BetterTextEditor.swift new file mode 100644 index 0000000..47746e2 --- /dev/null +++ b/Hotline/Library/Views/BetterTextEditor.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/Views/FileIconView.swift b/Hotline/Library/Views/FileIconView.swift new file mode 100644 index 0000000..d0949fa --- /dev/null +++ b/Hotline/Library/Views/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/Views/FileImageView.swift b/Hotline/Library/Views/FileImageView.swift new file mode 100644 index 0000000..0cc50f1 --- /dev/null +++ b/Hotline/Library/Views/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/Views/GroupedIconView.swift b/Hotline/Library/Views/GroupedIconView.swift new file mode 100644 index 0000000..313fac6 --- /dev/null +++ b/Hotline/Library/Views/GroupedIconView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct GroupedIconView: View { + let color: Color + let systemName: String + var padding: CGFloat = 6.0 + var cornerRadius: CGFloat = 8.0 + + var body: some View { + RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) + .fill(LinearGradient(colors: [self.color.mix(with: .white, by: 0.2), self.color], startPoint: .top, endPoint: .bottom)) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 1, x: 0, y: 1) + .overlay { + Image(systemName: self.systemName) + .resizable() + .scaledToFit() + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .white.opacity(0.4)) + .padding(self.padding) + .shadow(color: self.color.mix(with: .black, by: 0.5).opacity(0.2), radius: 0, x: 0, y: -1) + } + } +} diff --git a/Hotline/Library/Views/HotlinePanel.swift b/Hotline/Library/Views/HotlinePanel.swift new file mode 100644 index 0000000..d7d8284 --- /dev/null +++ b/Hotline/Library/Views/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 = true + self.level = .floating + self.hidesOnDeactivate = true + self.animationBehavior = .utilityWindow + + // Allow the panelto 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 = false + 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/Views/QuickLookPreviewView.swift b/Hotline/Library/Views/QuickLookPreviewView.swift new file mode 100644 index 0000000..6ba154e --- /dev/null +++ b/Hotline/Library/Views/QuickLookPreviewView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import Quartz + +/// Embeddable QuickLook preview view for macOS +/// +/// This view uses QLPreviewView to display file previews inline, without showing a modal. +/// Supports all file types that QuickLook supports (images, PDFs, videos, documents, etc.) +struct QuickLookPreviewView: NSViewRepresentable { + let fileURL: URL + + func makeNSView(context: Context) -> QLPreviewView { + let preview = QLPreviewView(frame: .zero, style: .normal)! + preview.autostarts = true + preview.shouldCloseWithWindow = true + preview.previewItem = fileURL as QLPreviewItem + return preview + } + + func updateNSView(_ nsView: QLPreviewView, context: Context) { + nsView.previewItem = fileURL as QLPreviewItem + } +} diff --git a/Hotline/Library/Views/SpinningGlobeView.swift b/Hotline/Library/Views/SpinningGlobeView.swift new file mode 100644 index 0000000..bccb969 --- /dev/null +++ b/Hotline/Library/Views/SpinningGlobeView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// An animated globe icon that cycles through different world regions +/// +/// Displays a spinning globe effect by cycling through SF Symbol images showing +/// different parts of the world. Useful for indicating network activity or global content. +/// +/// Example: +/// ```swift +/// SpinningGlobeView() +/// .frame(width: 16, height: 16) +/// +/// SpinningGlobeView(frameDelay: 0.5) +/// .frame(width: 24, height: 24) +/// ``` +struct SpinningGlobeView: View { + /// Delay between frames in seconds (default: 0.3) + let frameDelay: TimeInterval + + /// SF Symbol names for each frame of the globe animation + private let globeFrames = [ + "globe.americas.fill", + "globe.europe.africa.fill", + "globe.central.south.asia.fill", + "globe.asia.australia.fill" + ] + + @State private var currentFrameIndex = 0 + @State private var animationTask: Task<Void, Never>? + + init(frameDelay: TimeInterval = 0.25) { + self.frameDelay = frameDelay + } + + var body: some View { + Image(systemName: globeFrames[currentFrameIndex]) + .onAppear { + startAnimation() + } + .onDisappear { + stopAnimation() + } + } + + private func startAnimation() { + animationTask = Task { + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(frameDelay * 1_000_000_000)) + + guard !Task.isCancelled else { break } + + currentFrameIndex = (currentFrameIndex + 1) % globeFrames.count + } + } + } + + private func stopAnimation() { + animationTask?.cancel() + animationTask = nil + } +} + +#Preview { + VStack(spacing: 20) { + HStack(spacing: 20) { + SpinningGlobeView() + .frame(width: 12, height: 12) + + SpinningGlobeView() + .frame(width: 16, height: 16) + + SpinningGlobeView() + .frame(width: 24, height: 24) + } + + Text("Different frame delays:") + + HStack(spacing: 20) { + SpinningGlobeView(frameDelay: 0.1) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.3) + .frame(width: 16, height: 16) + + SpinningGlobeView(frameDelay: 0.6) + .frame(width: 16, height: 16) + } + } + .padding() +} diff --git a/Hotline/Library/Views/VisualEffectView.swift b/Hotline/Library/Views/VisualEffectView.swift new file mode 100644 index 0000000..e595c55 --- /dev/null +++ b/Hotline/Library/Views/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 + } +} |