import AppKit import Combine import SwiftUI /** The viewer's one window: the selected installation's dashboard and — when there is more than one — a slim tab bar of their names. In a window the bar floats over the top of the dashboard, just under the title bar: it comes out while the pointer is on the title bar and tucks away after. It is deliberately not part of the title bar — anything there makes WebKit push the whole page down to clear it, and a dashboard jumping every time the bar came and went would be worse than no bar at all. In full screen the bar *is* part of the title bar (a titlebar accessory), so it comes down with the menu bar like any Mac app's toolbar; there the title bar lives in a window of its own and the page stays put. */ final class ViewerWindowController: NSWindowController, NSWindowDelegate, NSMenuItemValidation { static let tabBarHeight: CGFloat = 32 /// The standard title bar's height: where the dashboard starts. private static let titlebarHeight: CGFloat = { let probe = NSRect(x: 0, y: 0, width: 100, height: 100) return NSWindow.frameRect(forContentRect: probe, styleMask: [.titled]).height - probe.height }() /// Empty state's "Add Connection…". var onAddConnection: () -> Void = {} private let store = ConnectionStore.shared private let settings = ViewerSettings.shared private var pages: [UUID: DashboardPage] = [:] private var order: [UUID] = [] private var selection: UUID? private let content = ViewerContentView() private let pageArea = NSView() private var pageAreaTop: NSLayoutConstraint! private var emptyState: NSView! private let tabModel = TabBarModel() private var tabOverlay: TabBarOverlay! private var tabOverlayTop: NSLayoutConstraint! private let tabAccessory = NSTitlebarAccessoryViewController() private var accessoryAttached = false private var tabBarShown = false private var hideTimer: Timer? private var inFullScreen = false private var leaving = false private var awakeActivity: NSObjectProtocol? private var keyMonitor: Any? private var cancellables = Set() private var currentPage: DashboardPage? { selection.flatMap { pages[$0] } } private var hasTabs: Bool { order.count > 1 } convenience init() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1280, height: 800), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) window.title = "ProdDash Viewer" window.minSize = NSSize(width: 640, height: 420) window.tabbingMode = .disallowed // the tab bar here is ProdDash's, not AppKit's window.collectionBehavior.insert(.fullScreenPrimary) window.isRestorable = false // frame and full screen are kept below instead window.center() window.setFrameAutosaveName("ProdDashViewerWindow") self.init(window: window) window.delegate = self build(in: window) } private func build(in window: NSWindow) { // The content runs under the title bar so the pointer can be watched // there; the dashboard itself starts below it. window.contentView = content pageArea.translatesAutoresizingMaskIntoConstraints = false content.addSubview(pageArea) pageAreaTop = pageArea.topAnchor.constraint(equalTo: content.topAnchor, constant: Self.titlebarHeight) let empty = NSHostingView(rootView: EmptyStateView { [weak self] in self?.onAddConnection() }) empty.sizingOptions = [] // sized by the window, never the other way round empty.translatesAutoresizingMaskIntoConstraints = false content.addSubview(empty) emptyState = empty tabOverlay = TabBarOverlay(model: tabModel) tabOverlay.translatesAutoresizingMaskIntoConstraints = false tabOverlay.isHidden = true tabOverlay.alphaValue = 0 content.addSubview(tabOverlay) tabOverlayTop = tabOverlay.topAnchor.constraint(equalTo: content.topAnchor, constant: hiddenOverlayTop) NSLayoutConstraint.activate([ pageAreaTop, pageArea.leadingAnchor.constraint(equalTo: content.leadingAnchor), pageArea.trailingAnchor.constraint(equalTo: content.trailingAnchor), pageArea.bottomAnchor.constraint(equalTo: content.bottomAnchor), empty.topAnchor.constraint(equalTo: pageArea.topAnchor), empty.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor), empty.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor), empty.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor), tabOverlayTop, tabOverlay.leadingAnchor.constraint(equalTo: content.leadingAnchor), tabOverlay.trailingAnchor.constraint(equalTo: content.trailingAnchor), tabOverlay.heightAnchor.constraint(equalToConstant: Self.tabBarHeight), ]) let bar = TabBarHostingView(rootView: TabBar(model: tabModel)) bar.sizingOptions = [] bar.frame = NSRect(x: 0, y: 0, width: 800, height: Self.tabBarHeight) tabAccessory.view = bar tabAccessory.layoutAttribute = .bottom tabAccessory.fullScreenMinHeight = 0 // out of sight until the menu bar comes down tabModel.onSelect = { [weak self] id in self?.select(id) } content.topZoneHeight = { [weak self] in self?.revealZoneHeight ?? 0 } content.onPointerInTopZone = { [weak self] inside in self?.pointerInTopZone(inside) } selection = settings.selectedConnection // @Published sends before it stores, so use the value it sends. store.$connections .sink { [weak self] connections in self?.sync(connections) } .store(in: &cancellables) settings.$keepDisplayAwake .sink { [weak self] keep in self?.updateDisplayAwake(keep) } .store(in: &cancellables) installKeyboardSwitching() } // MARK: - Connections and tabs private func sync(_ connections: [Connection]) { let ids = Set(connections.map(\.id)) let hadTabs = hasTabs for (id, page) in pages where !ids.contains(id) { page.close() pages[id] = nil } for connection in connections { if let page = pages[connection.id] { page.update(connection) } else { let page = DashboardPage(connection: connection) { [weak self] in self?.window } page.onChange = { [weak self, weak page] in if let page { self?.pageChanged(page) } } pages[connection.id] = page // A dashboard behind the one on screen still lays itself out // at the window's size, not at nothing. page.view.frame = NSRect(x: 0, y: 0, width: content.bounds.width, height: max(content.bounds.height - pageAreaTop.constant, 0)) page.view.layoutSubtreeIfNeeded() page.load() } } order = connections.map(\.id) if selection.map(ids.contains) != true { selection = order.first } showSelection() refreshTabs() placeTabBar() if hasTabs && !hadTabs { flashTabBar(for: 3) } // out long enough to be noticed updateDisplayAwake(settings.keepDisplayAwake) } private func pageChanged(_ page: DashboardPage) { refreshTabs() if page === currentPage { updateTitle() // A new address means a new web view: put it on screen. if page.view.superview !== pageArea { showSelection() } // The web view can't take the keyboard until it's shown something. if page.status.phase == .showing, (window?.firstResponder as? NSView)?.isDescendant(of: page.webView) != true { window?.makeFirstResponder(page.webView) } } } private func showSelection() { let page = currentPage for view in pageArea.subviews where view !== page?.view { view.removeFromSuperview() } if let page, page.view.superview !== pageArea { page.view.translatesAutoresizingMaskIntoConstraints = false pageArea.addSubview(page.view) NSLayoutConstraint.activate([ page.view.leadingAnchor.constraint(equalTo: pageArea.leadingAnchor), page.view.trailingAnchor.constraint(equalTo: pageArea.trailingAnchor), page.view.topAnchor.constraint(equalTo: pageArea.topAnchor), page.view.bottomAnchor.constraint(equalTo: pageArea.bottomAnchor), ]) } emptyState.isHidden = page != nil if let page { window?.makeFirstResponder(page.webView) } settings.selectedConnection = selection updateTitle() } private func updateTitle() { window?.title = currentPage?.connection.displayName ?? "ProdDash Viewer" } private func refreshTabs() { tabModel.tabs = order.compactMap { id in pages[id].map { TabBarModel.Tab(id: id, title: $0.connection.displayName, address: $0.connection.displayAddress, unreachable: $0.isUnreachable) } } tabModel.selection = selection } func select(_ id: UUID) { guard pages[id] != nil else { return } if id != selection { selection = id showSelection() refreshTabs() } flashTabBar(for: 2) } private func select(index: Int) { guard order.indices.contains(index) else { return } select(order[index]) } private func step(_ delta: Int) { guard !order.isEmpty else { return } let index = selection.flatMap { order.firstIndex(of: $0) } ?? 0 select(index: (index + delta + order.count) % order.count) } /// ⌃Tab / ⌃⇧Tab and ⌘1…⌘9 (⌘9 the last), as in Safari. Not menu items: /// the menus stay as short as the viewer is. private func installKeyboardSwitching() { keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self, event.window === self.window, self.hasTabs else { return event } // Only the keys that are pressed together — Caps Lock doesn't count. let mods = event.modifierFlags.intersection([.command, .control, .option, .shift]) if event.keyCode == 48, mods.subtracting(.shift) == .control { // Tab self.step(mods.contains(.shift) ? -1 : 1) return nil } if mods == .command, let digit = event.charactersIgnoringModifiers.flatMap(Int.init), (1...9).contains(digit) { self.select(index: digit == 9 ? self.order.count - 1 : digit - 1) return nil } return event } } // MARK: - The tab bar coming and going /// Overlay in a window, titlebar accessory in full screen — and neither /// with only one connection. The accessory stays attached whenever there /// are tabs, so it is already in place when AppKit builds the full-screen /// title bar; in a window it is hidden, and hidden it takes no room, so /// the page stays put. private func placeTabBar() { tabAccessory.isHidden = !inFullScreen if hasTabs != accessoryAttached { accessoryAttached = hasTabs if hasTabs { window?.addTitlebarAccessoryViewController(tabAccessory) } else { tabAccessory.removeFromParent() } } if !hasTabs { hideTimer?.invalidate() setTabBar(shown: false, animated: false) } content.updateTrackingAreas() } /// Under the title bar in a window; the top of the screen in full screen, /// where it only flashes up after a switch. private var shownOverlayTop: CGFloat { inFullScreen ? 0 : Self.titlebarHeight } /// Slid up by a few points while hidden, so it slides down as it fades in. private var hiddenOverlayTop: CGFloat { shownOverlayTop - 8 } /// The strip along the top that brings the bar out: the title bar, a /// sliver of the dashboard below it, and the bar itself while it's out. private var revealZoneHeight: CGFloat { guard hasTabs, !inFullScreen else { return 0 } return Self.titlebarHeight + (tabBarShown ? Self.tabBarHeight : 4) } private func pointerInTopZone(_ inside: Bool) { if inside { hideTimer?.invalidate() setTabBar(shown: true) } else { scheduleHide(after: 0.8) } } /// Bring the bar out for a moment — at launch, and after switching, so /// it's clear what changed. In full screen too, where nothing else says /// which installation is on screen. private func flashTabBar(for seconds: TimeInterval) { guard hasTabs else { return } setTabBar(shown: true) if !content.pointerInTopZone { scheduleHide(after: seconds) } } private func scheduleHide(after delay: TimeInterval) { hideTimer?.invalidate() hideTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in guard let self, !self.content.pointerInTopZone else { return } self.setTabBar(shown: false) } } private func setTabBar(shown: Bool, animated: Bool = true) { let shown = shown && hasTabs guard shown != tabBarShown else { return } tabBarShown = shown if shown { tabOverlay.isHidden = false } content.layoutSubtreeIfNeeded() NSAnimationContext.runAnimationGroup({ context in context.duration = animated ? 0.18 : 0 context.allowsImplicitAnimation = true tabOverlay.animator().alphaValue = shown ? 1 : 0 tabOverlayTop.animator().constant = shown ? shownOverlayTop : hiddenOverlayTop content.layoutSubtreeIfNeeded() }, completionHandler: { [weak self] in guard let self, !self.tabBarShown else { return } self.tabOverlay.isHidden = true }) content.updateTrackingAreas() } // MARK: - Full screen func windowWillEnterFullScreen(_ notification: Notification) { settleOverlay() inFullScreen = true pageAreaTop.constant = 0 placeTabBar() tabOverlayTop.constant = hiddenOverlayTop } func windowDidEnterFullScreen(_ notification: Notification) { if !leaving { settings.reopenInFullScreen = true } } /// The accessory hides before the window is a window again — showing in /// a window's title bar, it would push the dashboard down. func windowWillExitFullScreen(_ notification: Notification) { settleOverlay() inFullScreen = false pageAreaTop.constant = Self.titlebarHeight placeTabBar() tabOverlayTop.constant = hiddenOverlayTop } /// No overlay riding through the full-screen animation. private func settleOverlay() { hideTimer?.invalidate() setTabBar(shown: false, animated: false) } func windowDidExitFullScreen(_ notification: Notification) { if !leaving { settings.reopenInFullScreen = false } content.updateTrackingAreas() } /// Called on the way out, before AppKit takes the window out of full /// screen on its own account. func rememberFullScreen() { guard !leaving, let window else { return } leaving = true settings.reopenInFullScreen = window.styleMask.contains(.fullScreen) } // MARK: - Window /// Before AppKit takes a full-screen window out of full screen to close it. func windowShouldClose(_ sender: NSWindow) -> Bool { rememberFullScreen() return true } /// One window is the whole app: closing it quits, as in System Settings. func windowWillClose(_ notification: Notification) { rememberFullScreen() DispatchQueue.main.async { NSApp.terminate(nil) } } func windowDidChangeOcclusionState(_ notification: Notification) { updateDisplayAwake(settings.keepDisplayAwake) } /// Hold the display awake while a dashboard is actually on screen — not /// while the window is minimised, hidden or covered. private func updateDisplayAwake(_ keep: Bool) { let visible = window?.occlusionState.contains(.visible) == true let wanted = keep && visible && !order.isEmpty if wanted, awakeActivity == nil { awakeActivity = ProcessInfo.processInfo.beginActivity( options: [.idleDisplaySleepDisabled], reason: "Showing a ProdDash dashboard") } else if !wanted, let activity = awakeActivity { ProcessInfo.processInfo.endActivity(activity) awakeActivity = nil } } // MARK: - View menu @objc func goBackInPage(_ sender: Any?) { currentPage?.goBack() } @objc func goForwardInPage(_ sender: Any?) { currentPage?.goForward() } @objc func reloadPage(_ sender: Any?) { currentPage?.reload() } func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { case #selector(goBackInPage(_:)): return currentPage?.webView.canGoBack == true case #selector(goForwardInPage(_:)): return currentPage?.webView.canGoForward == true case #selector(reloadPage(_:)): return currentPage != nil default: return true } } } /// The tab bar as it shows in a window: the title bar's material, the tabs, /// and a hairline under them, floating over the top of the dashboard. final class TabBarOverlay: NSView { init(model: TabBarModel) { super.init(frame: .zero) let material = NSVisualEffectView() material.material = .titlebar material.blendingMode = .withinWindow material.state = .followsWindowActiveState let tabs = TabBarHostingView(rootView: TabBar(model: model)) tabs.sizingOptions = [] let rule = Hairline() for view in [material, tabs, rule] { view.translatesAutoresizingMaskIntoConstraints = false addSubview(view) } NSLayoutConstraint.activate([ material.leadingAnchor.constraint(equalTo: leadingAnchor), material.trailingAnchor.constraint(equalTo: trailingAnchor), material.topAnchor.constraint(equalTo: topAnchor), material.bottomAnchor.constraint(equalTo: bottomAnchor), tabs.leadingAnchor.constraint(equalTo: leadingAnchor), tabs.trailingAnchor.constraint(equalTo: trailingAnchor), tabs.topAnchor.constraint(equalTo: topAnchor), tabs.bottomAnchor.constraint(equalTo: rule.topAnchor), rule.leadingAnchor.constraint(equalTo: leadingAnchor), rule.trailingAnchor.constraint(equalTo: trailingAnchor), rule.bottomAnchor.constraint(equalTo: bottomAnchor), rule.heightAnchor.constraint(equalToConstant: 1), ]) } required init?(coder: NSCoder) { fatalError("not used") } /// The title bar's separator line. (An NSBox separator would do, but one /// laid out from a zero frame decides it's vertical and pins the window /// to a point wide.) private final class Hairline: NSView { override func draw(_ dirtyRect: NSRect) { NSColor.separatorColor.setFill() bounds.fill() } } } /// The window's content view. It watches the strip along the top of the /// window — which, with the content drawn under the title bar, includes the /// title bar — and says when the pointer comes and goes. Tracking stays on /// while another app is in front, so the bar answers the pointer even then. final class ViewerContentView: NSView { var topZoneHeight: () -> CGFloat = { 0 } var onPointerInTopZone: (Bool) -> Void = { _ in } private var zone: NSTrackingArea? private var zoneRect: NSRect { let height = min(topZoneHeight(), bounds.height) return NSRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height) } var pointerInTopZone: Bool { guard let window else { return false } let rect = zoneRect return rect.height > 0 && rect.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil)) } override func updateTrackingAreas() { super.updateTrackingAreas() if let zone { removeTrackingArea(zone) } zone = nil let rect = zoneRect guard rect.height > 0 else { return } let area = NSTrackingArea(rect: rect, options: [.mouseEnteredAndExited, .activeAlways], owner: self) addTrackingArea(area) zone = area } override func mouseEntered(with event: NSEvent) { if event.trackingArea === zone { onPointerInTopZone(true) } } override func mouseExited(with event: NSEvent) { if event.trackingArea === zone { onPointerInTopZone(false) } } } /// The window before any connection exists. struct EmptyStateView: View { let add: () -> Void var body: some View { VStack(spacing: 14) { Image(nsImage: NSApp.applicationIconImage) .resizable() .frame(width: 96, height: 96) Text("No ProdDash Connections") .font(.title2.weight(.semibold)) Text("Add the address of a ProdDash installation and its dashboard opens here. Add more than one to switch between them.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) .frame(maxWidth: 380) .fixedSize(horizontal: false, vertical: true) Button("Add Connection…", action: add) .controlSize(.large) .keyboardShortcut(.defaultAction) .padding(.top, 6) } .padding(40) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) } }