aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2024-01-05 19:46:13 -0800
committerDustin Mierau <dustin@mierau.me>2024-01-05 19:46:37 -0800
commit2f8f71d0410f59d5d303a4c221a628630168bfe2 (patch)
tree6a18ae2e4dbd72a688667bd9e819ecee72d76b52 /Hotline/macOS
parent57c33eb798f6802fe5af8fcd3d1992a4b63a448f (diff)
Add image preview to Files so you don't have to download images to view them, works with animated GIFs too. Added cmd-R shortcut for Servers window. More work on login view.
Diffstat (limited to 'Hotline/macOS')
-rw-r--r--Hotline/macOS/FileImageView.swift49
-rw-r--r--Hotline/macOS/FilePreviewView.swift333
-rw-r--r--Hotline/macOS/FilesView.swift31
-rw-r--r--Hotline/macOS/ServerView.swift156
-rw-r--r--Hotline/macOS/TrackerView.swift34
5 files changed, 473 insertions, 130 deletions
diff --git a/Hotline/macOS/FileImageView.swift b/Hotline/macOS/FileImageView.swift
deleted file mode 100644
index 99fd7b5..0000000
--- a/Hotline/macOS/FileImageView.swift
+++ /dev/null
@@ -1,49 +0,0 @@
-import SwiftUI
-
-enum FileImageViewStatus {
- case notloaded
- case loading
- case loaded
-}
-
-enum FileImageType {
- case banner
- case file
-}
-
-struct FileImageView: View {
- @Environment(Hotline.self) private var model: Hotline
- @State var status: FileImageViewStatus = .notloaded
-
- @MainActor func startLoading() {
- if self.status != .notloaded {
- return
- }
-
- self.status = .loading
- self.model.downloadBanner()
- }
-
- var body: some View {
- HStack(spacing: 0) {
- if let img = self.model.bannerImage {
- #if os(macOS)
- Image(nsImage: img)
- .resizable()
- .scaledToFit()
- #elseif os(iOS)
- Image(uiImage: img)
- .resizable()
- .scaledToFit()
- #endif
- }
- }
- .task {
- self.startLoading()
- }
- }
-}
-
-#Preview {
- FileImageView()
-}
diff --git a/Hotline/macOS/FilePreviewView.swift b/Hotline/macOS/FilePreviewView.swift
new file mode 100644
index 0000000..d170dd7
--- /dev/null
+++ b/Hotline/macOS/FilePreviewView.swift
@@ -0,0 +1,333 @@
+import SwiftUI
+
+class FilePreviewWindowController: NSWindowController, NSWindowDelegate {
+ init(info: PreviewFileInfo) {
+ let window = PreviewWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
+ styleMask: [.titled, .unifiedTitleAndToolbar, .closable, .miniaturizable, .resizable, .fullSizeContentView],
+ backing: .buffered, defer: false)
+
+ super.init(window: window)
+
+ window.toolbarStyle = .unified
+ window.delegate = self
+ window.title = info.name
+ window.acceptsMouseMovedEvents = true
+ window.appearance = NSAppearance(named: .darkAqua)
+ window.collectionBehavior = .fullScreenAuxiliary
+
+ window.standardWindowButton(.closeButton)?.isHidden = false
+ window.standardWindowButton(.miniaturizeButton)?.isHidden = false
+ window.standardWindowButton(.zoomButton)?.isHidden = false
+
+ window.center()
+
+ let rootView = PreviewWindowView(frame: NSRect(x: 0, y: 0, width: 480, height: 300))
+ rootView.autoresizingMask = [.width, .height]
+
+ let hostingView = NSHostingView(rootView: FilePreviewView(info: info))
+ hostingView.frame = rootView.bounds
+ hostingView.autoresizingMask = [.width, .height]
+ rootView.addSubview(hostingView)
+
+ window.contentView = rootView
+
+// let toolbar = NSToolbar()
+// toolbar.allowsUserCustomization = false
+// toolbar.displayMode = .iconOnly
+//
+// window.toolbar = toolbar
+
+ self.showWindow(nil)
+ window.makeFirstResponder(nil)
+ }
+
+ deinit {
+ print("FilePreviewWindowController: dealloc")
+ }
+
+ required init?(coder: NSCoder) {
+ return nil
+ }
+}
+
+struct FilePreviewToolbar: View {
+ var body: some View {
+// ToolbarItem(placement: .primaryAction) {
+ Button {
+
+ } label: {
+ Image(systemName: "plus")
+ }
+ .buttonStyle(.accessoryBar)
+// }
+ }
+}
+
+struct FilePreviewView: View {
+ enum FilePreviewFocus: Hashable {
+ case window
+ }
+
+ @Environment(\.controlActiveState) private var controlActiveState
+ @Environment(\.colorScheme) private var colorScheme
+
+ let info: PreviewFileInfo
+
+ @State var preview: FilePreview? = nil
+ @Namespace var mainNamespace
+ @FocusState private var focusField: FilePreviewFocus?
+
+ var body: some View {
+ Group {
+ if preview?.state != .loaded {
+ ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0)))
+ .focusable(false)
+ .padding()
+ .accentColor(colorScheme == .dark ? .white : .black)
+ .frame(maxWidth: 250)
+ .focusEffectDisabled()
+ }
+ else {
+ if let img = preview?.image {
+ AnimatedImageView(image: img)
+ .focusable(false)
+ .scaledToFit()
+ .focusEffectDisabled()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ else {
+ Text("UNSUPPORTED FORMAT:")
+ }
+ }
+ }
+ .focusable()
+ .focusEffectDisabled()
+ .focused($focusField, equals: .window)
+// .prefersDefaultFocus(in: mainNamespace)
+// .focusScope(mainNamespace)
+// .focusSection()
+
+ .ignoresSafeArea()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.black)
+ .overlay(DraggableWindowView())
+ .task {
+ preview = FilePreview(info: info)
+ preview?.download()
+ }
+ .onAppear {
+ focusField = .window
+ }
+ .onDisappear {
+ preview?.cancel()
+ }
+ }
+}
+
+fileprivate struct AnimatedImageView: NSViewRepresentable {
+ var image: NSImage?
+
+ 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
+ }
+
+ 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) {
+ if let window = imageView.window {
+ window.aspectRatio = img.size
+
+ var windowRect = window.frame
+ let centerPoint = CGPoint(x: windowRect.midX, y: windowRect.midY)
+
+ windowRect.size = img.size
+
+ if let screen = window.screen {
+ var paddedScreenSize = screen.frame.size
+ paddedScreenSize.width *= 0.5
+ paddedScreenSize.height *= 0.5
+
+ window.minSize = aspectFit(source: windowRect.size, bounds: CGSize(width: 400, height: 400))
+ windowRect.size = aspectFit(source: windowRect.size, bounds: paddedScreenSize, minimum: window.minSize)
+ }
+
+ 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)
+ }
+ }
+ }
+
+ return imageView
+ }
+
+ func updateNSView(_ nsView: NSImageView, context: Context) {
+ nsView.image = self.image
+ }
+}
+
+fileprivate struct DraggableWindowView: NSViewRepresentable {
+ func makeNSView(context: Context) -> NSView {
+ return DraggableWindowNSView()
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) { }
+}
+
+fileprivate class DraggableWindowNSView: NSView {
+ override public func mouseDown(with event: NSEvent) {
+ window?.performDrag(with: event)
+ }
+}
+
+fileprivate class PreviewWindow: NSWindow {
+ override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
+ super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
+ self.minSize = NSSize(width: 200, height: 200)
+ self.isReleasedWhenClosed = true
+ self.titlebarSeparatorStyle = .line
+ self.acceptsMouseMovedEvents = true
+ }
+
+ deinit {
+ print("FilePreviewWindow: dealloc")
+ }
+
+ override var canBecomeMain: Bool { return true }
+ override var canBecomeKey: Bool { return true }
+}
+
+fileprivate class PreviewWindowView: NSView {
+ private var trackingArea: NSTrackingArea? = nil
+ private var windowIsFullscreen: Bool = false
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+
+ self.updateTrackingAreas()
+ }
+
+ required init?(coder: NSCoder) {
+ return nil
+ }
+
+ deinit {
+ print("FilePreviewWindowView: dealloc")
+ }
+
+ override func updateTrackingAreas() {
+ if let t = self.trackingArea {
+ self.removeTrackingArea(t)
+ self.trackingArea = nil
+ }
+
+ let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways]
+ self.trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
+ self.addTrackingArea(self.trackingArea!)
+ }
+
+// override func viewDidMoveToWindow() {
+// print("VIEW MOVED TO WINDOW?")
+// if let w = self.window {
+//// w.titlebarAppearsTransparent = false
+// let toolbarButtons = NSHostingView(rootView: FilePreviewToolbar())
+// toolbarButtons.frame.size = toolbarButtons.fittingSize
+//
+// let titlebarAccessory = NSTitlebarAccessoryViewController()
+// titlebarAccessory.automaticallyAdjustsSize = true
+// titlebarAccessory.view = toolbarButtons
+// titlebarAccessory.layoutAttribute = .trailing
+// w.addTitlebarAccessoryViewController(titlebarAccessory)
+// }
+// }
+
+ override func mouseEntered(with event: NSEvent) {
+ if self.windowIsFullscreen {
+ return
+ }
+
+ if let w = self.window {
+ w.titlebarAppearsTransparent = false
+ w.titleVisibility = .visible
+ w.standardWindowButton(.closeButton)?.isHidden = false
+ w.standardWindowButton(.miniaturizeButton)?.isHidden = false
+ w.standardWindowButton(.zoomButton)?.isHidden = false
+ w.toolbar?.isVisible = true
+ }
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ if self.windowIsFullscreen {
+ return
+ }
+
+ if let w = self.window {
+ w.titlebarAppearsTransparent = true
+ w.titleVisibility = .hidden
+ w.standardWindowButton(.closeButton)?.isHidden = true
+ w.standardWindowButton(.miniaturizeButton)?.isHidden = true
+ w.standardWindowButton(.zoomButton)?.isHidden = true
+ w.toolbar?.isVisible = false
+ }
+ }
+
+ func setWindowIsFullscreen(_ isFullscreen: Bool) {
+ self.windowIsFullscreen = isFullscreen
+
+ if let w = self.window, isFullscreen {
+ w.titlebarAppearsTransparent = false
+ w.titleVisibility = .visible
+ w.standardWindowButton(.closeButton)?.isHidden = false
+ w.standardWindowButton(.miniaturizeButton)?.isHidden = false
+ w.standardWindowButton(.zoomButton)?.isHidden = false
+ }
+ }
+
+ override var acceptsFirstResponder: Bool { return true }
+
+ override func keyDown(with event: NSEvent) {
+ switch event.charactersIgnoringModifiers?.first {
+ case " ":
+ self.window?.performClose(nil)
+ default:
+ break
+ }
+ }
+}
diff --git a/Hotline/macOS/FilesView.swift b/Hotline/macOS/FilesView.swift
index 09354ed..5443270 100644
--- a/Hotline/macOS/FilesView.swift
+++ b/Hotline/macOS/FilesView.swift
@@ -70,8 +70,7 @@ struct FileView: View {
.padding(.leading, CGFloat(depth * (12 + 10)))
.onChange(of: file.expanded) {
loading = false
- if file.isFolder {
- print("EXPANDED \(file.name)? \(expanded)")
+ if file.isFolder && file.fileSize > 0 {
if file.expanded {
Task {
loading = true
@@ -108,9 +107,14 @@ struct FileView: View {
struct FilesView: View {
@Environment(Hotline.self) private var model: Hotline
+ @Environment(\.openWindow) private var openWindow
@State private var selection: FileInfo?
+ private func openPreviewWindow(_ previewInfo: PreviewFileInfo) {
+ let _ = FilePreviewWindowController(info: previewInfo)
+ }
+
var body: some View {
NavigationStack {
List(model.files, id: \.self, selection: $selection) { file in
@@ -129,7 +133,6 @@ struct FilesView: View {
.contextMenu(forSelectionType: FileInfo.self) { items in
// ...
} primaryAction: { items in
- print("ITEMS?", items)
guard let clickedFile = items.first else {
return
}
@@ -139,7 +142,6 @@ struct FilesView: View {
clickedFile.expanded.toggle()
}
else {
- print("DOWNLOAD FILE", clickedFile.name, clickedFile.path)
model.downloadFile(clickedFile.name, path: clickedFile.path)
}
}
@@ -157,6 +159,17 @@ struct FilesView: View {
}
return .ignored
}
+ .onKeyPress(.space) {
+ if let s = selection, s.isImage {
+ model.previewFile(s.name, path: s.path) { info in
+ if let info = info {
+ openPreviewWindow(info)
+ }
+ }
+ return .handled
+ }
+ return .ignored
+ }
.overlay {
if !model.filesLoaded {
VStack {
@@ -167,8 +180,6 @@ struct FilesView: View {
}
}
.toolbar {
-
-
ToolbarItem(placement: .primaryAction) {
Button {
} label: {
@@ -180,15 +191,17 @@ struct FilesView: View {
ToolbarItem(placement: .primaryAction) {
Button {
if let s = selection, !s.isFolder {
- print("DOWNLOAD FILE", s.name, s.path)
- model.previewFile(s.name, path: s.path, addTransfer: true) { transfer, fileData in
- print("FILE PREVIEWED?", fileData.count)
+ model.previewFile(s.name, path: s.path) { info in
+ if let info = info {
+ openPreviewWindow(info)
+ }
}
}
} label: {
Label("Preview File", systemImage: "eye")
}
.help("Preview File")
+ .disabled(selection?.isImage == false)
}
ToolbarItem(placement: .primaryAction) {
diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift
index a7dc90d..ea0a090 100644
--- a/Hotline/macOS/ServerView.swift
+++ b/Hotline/macOS/ServerView.swift
@@ -2,8 +2,6 @@ import SwiftUI
import UniformTypeIdentifiers
enum MenuItemType {
- case banner
- case progress
case chat
case news
case messageBoard
@@ -171,8 +169,8 @@ struct ServerView: View {
@State private var connectLogin: String = ""
@State private var connectPassword: String = ""
- @Binding var server: Server?
-
+ @Binding var server: Server
+
static var menuItems = [
MenuItem(name: "Chat", image: "bubble", type: .chat),
MenuItem(name: "News", image: "newspaper", type: .news, serverVersion: 150),
@@ -196,6 +194,20 @@ struct ServerView: View {
}
.textFieldStyle(.roundedBorder)
.controlSize(.regular)
+ .onChange(of: connectAddress) {
+ let (a, p) = Server.parseServerAddressAndPort(connectAddress)
+ server.address = a
+ server.port = p
+ print("ADDRESS CHANGED: '\(connectAddress)' \(a) \(p)")
+ }
+ .onChange(of: connectLogin) {
+ server.login = connectLogin.trimmingCharacters(in: .whitespacesAndNewlines)
+ print("LOGIN CHANGED: '\(connectLogin)'" + $server.wrappedValue.login)
+ }
+ .onChange(of: connectPassword) {
+ server.password = connectPassword
+ print("PASS CHANGED: '\(connectPassword)'" + server.password)
+ }
HStack {
Button {
@@ -219,8 +231,21 @@ struct ServerView: View {
.keyboardShortcut(.cancelAction)
Button {
- let (a, p) = Server.parseServerAddressAndPort(connectAddress)
- server = Server(name: nil, description: nil, address: a, port: p, users: 0)
+
+// if var s = server {
+// print("CHANGING EXISTING SERVER")
+// s.name = newServer.name
+// s.description = newServer.description
+// s.users = newServer.users
+// s.address = newServer.address
+// s.port = newServer.port
+// s.login = newServer.login
+// s.password = newServer.password
+// }
+// else {
+// server = newServer
+// }
+
Task {
await connectToServer()
}
@@ -242,39 +267,26 @@ struct ServerView: View {
var navigationList: some View {
List(selection: $selection) {
-
- if model.status != .loggedIn {
- HStack {
- ProgressView(value: connectionStatusToProgress(status: model.status))
- .padding()
- }
- .tag(MenuItem(name: "progress", image: "", type: .progress))
- .frame(maxWidth: .infinity, minHeight: 60)
- .selectionDisabled()
- }
-
- if model.status == .loggedIn {
- ForEach(ServerView.menuItems) { menuItem in
- if let minServerVersion = menuItem.serverVersion {
- if let v = model.serverVersion, v >= minServerVersion {
- ListItemView(icon: menuItem.image, title: menuItem.name)
- .tag(menuItem)
- }
- }
- else {
+ ForEach(ServerView.menuItems) { menuItem in
+ if let minServerVersion = menuItem.serverVersion {
+ if let v = model.serverVersion, v >= minServerVersion {
ListItemView(icon: menuItem.image, title: menuItem.name)
.tag(menuItem)
}
}
-
- if model.transfers.count > 0 {
- self.transfersSection
- }
-
- if model.users.count > 0 {
- self.usersSection
+ else {
+ ListItemView(icon: menuItem.image, title: menuItem.name)
+ .tag(menuItem)
}
}
+
+ if model.transfers.count > 0 {
+ self.transfersSection
+ }
+
+ if model.users.count > 0 {
+ self.usersSection
+ }
}
}
@@ -319,10 +331,6 @@ struct ServerView: View {
} detail: {
if let selection = selection {
switch selection.type {
- case .banner:
- EmptyView()
- case .progress:
- EmptyView()
case .chat:
ChatView()
.navigationTitle(model.serverTitle)
@@ -404,18 +412,28 @@ struct ServerView: View {
model.disconnect()
}
.task {
- if server != nil {
- connectToServer()
+ var address = server.address
+ if server.port != HotlinePorts.DefaultServerPort {
+ address += ":\(server.port)"
}
+ connectAddress = server.address
+ connectLogin = server.login
+ connectPassword = server.password
+ connectToServer()
}
}
// MARK: -
- @MainActor func connectToServer(login: String = "", password: String = "") {
- model.login(server: server!, login: login, password: password, username: preferences.username, iconID: preferences.userIconID) { success in
+ @MainActor func connectToServer() {
+ guard !server.address.isEmpty else {
+ return
+ }
+
+ model.login(server: server, username: preferences.username, iconID: preferences.userIconID) { success in
if !success {
print("FAILED LOGIN??")
+ model.disconnect()
}
else {
print("GETTING USER LIST????!")
@@ -442,35 +460,35 @@ struct ServerView: View {
}
private func connectionStatusToLabel(status: HotlineClientStatus) -> String {
- if let s = self.server {
- let n = s.name ?? s.address
- switch status {
- case .disconnected:
- return "Disconnected"
- case .connecting:
- return "Connecting to \(n)..."
- case .connected:
- return "Connected to \(n)"
- case .loggingIn:
- return "Logging in to \(n)..."
- case .loggedIn:
- return "Logged in to \(n)"
- }
- }
- else {
- switch status {
- case .disconnected:
- return "Disconnected"
- case .connecting:
- return "Connecting..."
- case .connected:
- return "Connected"
- case .loggingIn:
- return "Logging in..."
- case .loggedIn:
- return "Logged in"
- }
+// if let s = self.server {
+ let n = server.name ?? server.address
+ switch status {
+ case .disconnected:
+ return "Disconnected"
+ case .connecting:
+ return "Connecting to \(n)..."
+ case .connected:
+ return "Connected to \(n)"
+ case .loggingIn:
+ return "Logging in to \(n)..."
+ case .loggedIn:
+ return "Logged in to \(n)"
}
+// }
+// else {
+// switch status {
+// case .disconnected:
+// return "Disconnected"
+// case .connecting:
+// return "Connecting..."
+// case .connected:
+// return "Connected"
+// case .loggingIn:
+// return "Logging in..."
+// case .loggedIn:
+// return "Logged in"
+// }
+// }
}
@MainActor func sendPreferences() {
diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift
index 2c9c839..268cd3e 100644
--- a/Hotline/macOS/TrackerView.swift
+++ b/Hotline/macOS/TrackerView.swift
@@ -9,6 +9,7 @@ struct TrackerBookmark {
let type: TrackerBookmarkType
let name: String
let address: String
+ let port: Int = HotlinePorts.DefaultServerPort
}
@Observable
@@ -22,6 +23,27 @@ class TrackerItem: Identifiable, Hashable {
var expanded: Bool = false
var loading: Bool = false
+ var displayAddress: String? {
+ if let s = server {
+ if s.port == HotlinePorts.DefaultServerPort {
+ return s.address
+ }
+ else {
+ return "\(s.address):\(s.port)"
+ }
+ }
+ else if let b = bookmark {
+ if b.port == HotlinePorts.DefaultServerPort {
+ return b.address
+ }
+ else {
+ return "\(b.address):\(b.port)"
+ }
+ }
+
+ return nil
+ }
+
init(bookmark: TrackerBookmark) {
self.bookmark = bookmark
self.server = nil
@@ -46,6 +68,7 @@ class TrackerItem: Identifiable, Hashable {
hasher.combine(self.id)
}
+
@MainActor
func loadServers() async {
guard
@@ -283,10 +306,10 @@ struct TrackerView: View {
.alternatingRowBackgrounds(.enabled)
.contextMenu(forSelectionType: TrackerItem.self) { items in
if let item = items.first {
- if let server = item.server {
+ if let address = item.displayAddress {
Button {
NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(server.address, forType: .string)
+ NSPasteboard.general.setString(address, forType: .string)
} label: {
Label("Copy Server Address", systemImage: "doc.on.doc")
}
@@ -371,7 +394,7 @@ struct TrackerView: View {
.help("Connect to Server...")
}
}
- .onAppear {
+ .task {
// Add initial items to tracker list.
var items: [TrackerItem] = []
for bookmark in self.bookmarks {
@@ -379,6 +402,11 @@ struct TrackerView: View {
}
self.servers = items
}
+ .onOpenURL(perform: { url in
+ if let s = Server(url: url) {
+ openWindow(id: "server", value: s)
+ }
+ })
}
}