diff options
| author | Dustin Mierau <dustin@mierau.me> | 2024-01-05 19:46:13 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2024-01-05 19:46:37 -0800 |
| commit | 2f8f71d0410f59d5d303a4c221a628630168bfe2 (patch) | |
| tree | 6a18ae2e4dbd72a688667bd9e819ecee72d76b52 /Hotline/macOS | |
| parent | 57c33eb798f6802fe5af8fcd3d1992a4b63a448f (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.swift | 49 | ||||
| -rw-r--r-- | Hotline/macOS/FilePreviewView.swift | 333 | ||||
| -rw-r--r-- | Hotline/macOS/FilesView.swift | 31 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 156 | ||||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 34 |
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) + } + }) } } |